##// END OF EJS Templates
Merged r3246 and r3247 from trunk....
Jean-Philippe Lang -
r3134:718cd596e061
parent child
Show More
@@ -1,317 +1,316
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class MailHandler < ActionMailer::Base
18 class MailHandler < ActionMailer::Base
19 include ActionView::Helpers::SanitizeHelper
19 include ActionView::Helpers::SanitizeHelper
20
20
21 class UnauthorizedAction < StandardError; end
21 class UnauthorizedAction < StandardError; end
22 class MissingInformation < StandardError; end
22 class MissingInformation < StandardError; end
23
23
24 attr_reader :email, :user
24 attr_reader :email, :user
25
25
26 def self.receive(email, options={})
26 def self.receive(email, options={})
27 @@handler_options = options.dup
27 @@handler_options = options.dup
28
28
29 @@handler_options[:issue] ||= {}
29 @@handler_options[:issue] ||= {}
30
30
31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
32 @@handler_options[:allow_override] ||= []
32 @@handler_options[:allow_override] ||= []
33 # Project needs to be overridable if not specified
33 # Project needs to be overridable if not specified
34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
35 # Status overridable by default
35 # Status overridable by default
36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
37
37
38 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
38 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
39 super email
39 super email
40 end
40 end
41
41
42 # Processes incoming emails
42 # Processes incoming emails
43 # Returns the created object (eg. an issue, a message) or false
43 # Returns the created object (eg. an issue, a message) or false
44 def receive(email)
44 def receive(email)
45 @email = email
45 @email = email
46 sender_email = email.from.to_a.first.to_s.strip
46 sender_email = email.from.to_a.first.to_s.strip
47 # Ignore emails received from the application emission address to avoid hell cycles
47 # Ignore emails received from the application emission address to avoid hell cycles
48 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
48 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
49 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
49 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
50 return false
50 return false
51 end
51 end
52 @user = User.find_by_mail(sender_email)
52 @user = User.find_by_mail(sender_email)
53 if @user && !@user.active?
53 if @user && !@user.active?
54 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
54 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
55 return false
55 return false
56 end
56 end
57 if @user.nil?
57 if @user.nil?
58 # Email was submitted by an unknown user
58 # Email was submitted by an unknown user
59 case @@handler_options[:unknown_user]
59 case @@handler_options[:unknown_user]
60 when 'accept'
60 when 'accept'
61 @user = User.anonymous
61 @user = User.anonymous
62 when 'create'
62 when 'create'
63 @user = MailHandler.create_user_from_email(email)
63 @user = MailHandler.create_user_from_email(email)
64 if @user
64 if @user
65 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
65 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
66 Mailer.deliver_account_information(@user, @user.password)
66 Mailer.deliver_account_information(@user, @user.password)
67 else
67 else
68 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
68 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
69 return false
69 return false
70 end
70 end
71 else
71 else
72 # Default behaviour, emails from unknown users are ignored
72 # Default behaviour, emails from unknown users are ignored
73 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
73 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
74 return false
74 return false
75 end
75 end
76 end
76 end
77 User.current = @user
77 User.current = @user
78 dispatch
78 dispatch
79 end
79 end
80
80
81 private
81 private
82
82
83 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
83 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
84 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
84 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
85 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
85 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
86
86
87 def dispatch
87 def dispatch
88 headers = [email.in_reply_to, email.references].flatten.compact
88 headers = [email.in_reply_to, email.references].flatten.compact
89 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
89 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
90 klass, object_id = $1, $2.to_i
90 klass, object_id = $1, $2.to_i
91 method_name = "receive_#{klass}_reply"
91 method_name = "receive_#{klass}_reply"
92 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
92 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
93 send method_name, object_id
93 send method_name, object_id
94 else
94 else
95 # ignoring it
95 # ignoring it
96 end
96 end
97 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
97 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
98 receive_issue_reply(m[1].to_i)
98 receive_issue_reply(m[1].to_i)
99 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
99 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
100 receive_message_reply(m[1].to_i)
100 receive_message_reply(m[1].to_i)
101 else
101 else
102 receive_issue
102 receive_issue
103 end
103 end
104 rescue ActiveRecord::RecordInvalid => e
104 rescue ActiveRecord::RecordInvalid => e
105 # TODO: send a email to the user
105 # TODO: send a email to the user
106 logger.error e.message if logger
106 logger.error e.message if logger
107 false
107 false
108 rescue MissingInformation => e
108 rescue MissingInformation => e
109 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
109 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
110 false
110 false
111 rescue UnauthorizedAction => e
111 rescue UnauthorizedAction => e
112 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
112 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
113 false
113 false
114 end
114 end
115
115
116 # Creates a new issue
116 # Creates a new issue
117 def receive_issue
117 def receive_issue
118 project = target_project
118 project = target_project
119 tracker = (get_keyword(:tracker) && project.trackers.find_by_name(get_keyword(:tracker))) || project.trackers.find(:first)
119 tracker = (get_keyword(:tracker) && project.trackers.find_by_name(get_keyword(:tracker))) || project.trackers.find(:first)
120 category = (get_keyword(:category) && project.issue_categories.find_by_name(get_keyword(:category)))
120 category = (get_keyword(:category) && project.issue_categories.find_by_name(get_keyword(:category)))
121 priority = (get_keyword(:priority) && IssuePriority.find_by_name(get_keyword(:priority)))
121 priority = (get_keyword(:priority) && IssuePriority.find_by_name(get_keyword(:priority)))
122 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
122 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
123
123
124 # check permission
124 # check permission
125 unless @@handler_options[:no_permission_check]
125 unless @@handler_options[:no_permission_check]
126 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
126 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
127 end
127 end
128
128
129 issue = Issue.new(:author => user, :project => project, :tracker => tracker, :category => category, :priority => priority)
129 issue = Issue.new(:author => user, :project => project, :tracker => tracker, :category => category, :priority => priority)
130 # check workflow
130 # check workflow
131 if status && issue.new_statuses_allowed_to(user).include?(status)
131 if status && issue.new_statuses_allowed_to(user).include?(status)
132 issue.status = status
132 issue.status = status
133 end
133 end
134 issue.subject = email.subject.chomp
134 issue.subject = email.subject.chomp
135 issue.subject = issue.subject.toutf8 if issue.subject.respond_to?(:toutf8)
136 if issue.subject.blank?
135 if issue.subject.blank?
137 issue.subject = '(no subject)'
136 issue.subject = '(no subject)'
138 end
137 end
139 # custom fields
138 # custom fields
140 issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
139 issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
141 if value = get_keyword(c.name, :override => true)
140 if value = get_keyword(c.name, :override => true)
142 h[c.id] = value
141 h[c.id] = value
143 end
142 end
144 h
143 h
145 end
144 end
146 issue.description = cleaned_up_text_body
145 issue.description = cleaned_up_text_body
147 # add To and Cc as watchers before saving so the watchers can reply to Redmine
146 # add To and Cc as watchers before saving so the watchers can reply to Redmine
148 add_watchers(issue)
147 add_watchers(issue)
149 issue.save!
148 issue.save!
150 add_attachments(issue)
149 add_attachments(issue)
151 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
150 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
152 issue
151 issue
153 end
152 end
154
153
155 def target_project
154 def target_project
156 # TODO: other ways to specify project:
155 # TODO: other ways to specify project:
157 # * parse the email To field
156 # * parse the email To field
158 # * specific project (eg. Setting.mail_handler_target_project)
157 # * specific project (eg. Setting.mail_handler_target_project)
159 target = Project.find_by_identifier(get_keyword(:project))
158 target = Project.find_by_identifier(get_keyword(:project))
160 raise MissingInformation.new('Unable to determine target project') if target.nil?
159 raise MissingInformation.new('Unable to determine target project') if target.nil?
161 target
160 target
162 end
161 end
163
162
164 # Adds a note to an existing issue
163 # Adds a note to an existing issue
165 def receive_issue_reply(issue_id)
164 def receive_issue_reply(issue_id)
166 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
165 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
167
166
168 issue = Issue.find_by_id(issue_id)
167 issue = Issue.find_by_id(issue_id)
169 return unless issue
168 return unless issue
170 # check permission
169 # check permission
171 unless @@handler_options[:no_permission_check]
170 unless @@handler_options[:no_permission_check]
172 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
171 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
173 raise UnauthorizedAction unless status.nil? || user.allowed_to?(:edit_issues, issue.project)
172 raise UnauthorizedAction unless status.nil? || user.allowed_to?(:edit_issues, issue.project)
174 end
173 end
175
174
176 # add the note
175 # add the note
177 journal = issue.init_journal(user, cleaned_up_text_body)
176 journal = issue.init_journal(user, cleaned_up_text_body)
178 add_attachments(issue)
177 add_attachments(issue)
179 # check workflow
178 # check workflow
180 if status && issue.new_statuses_allowed_to(user).include?(status)
179 if status && issue.new_statuses_allowed_to(user).include?(status)
181 issue.status = status
180 issue.status = status
182 end
181 end
183 issue.save!
182 issue.save!
184 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
183 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
185 journal
184 journal
186 end
185 end
187
186
188 # Reply will be added to the issue
187 # Reply will be added to the issue
189 def receive_journal_reply(journal_id)
188 def receive_journal_reply(journal_id)
190 journal = Journal.find_by_id(journal_id)
189 journal = Journal.find_by_id(journal_id)
191 if journal && journal.journalized_type == 'Issue'
190 if journal && journal.journalized_type == 'Issue'
192 receive_issue_reply(journal.journalized_id)
191 receive_issue_reply(journal.journalized_id)
193 end
192 end
194 end
193 end
195
194
196 # Receives a reply to a forum message
195 # Receives a reply to a forum message
197 def receive_message_reply(message_id)
196 def receive_message_reply(message_id)
198 message = Message.find_by_id(message_id)
197 message = Message.find_by_id(message_id)
199 if message
198 if message
200 message = message.root
199 message = message.root
201
200
202 unless @@handler_options[:no_permission_check]
201 unless @@handler_options[:no_permission_check]
203 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
202 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
204 end
203 end
205
204
206 if !message.locked?
205 if !message.locked?
207 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
206 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
208 :content => cleaned_up_text_body)
207 :content => cleaned_up_text_body)
209 reply.author = user
208 reply.author = user
210 reply.board = message.board
209 reply.board = message.board
211 message.children << reply
210 message.children << reply
212 add_attachments(reply)
211 add_attachments(reply)
213 reply
212 reply
214 else
213 else
215 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
214 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
216 end
215 end
217 end
216 end
218 end
217 end
219
218
220 def add_attachments(obj)
219 def add_attachments(obj)
221 if email.has_attachments?
220 if email.has_attachments?
222 email.attachments.each do |attachment|
221 email.attachments.each do |attachment|
223 Attachment.create(:container => obj,
222 Attachment.create(:container => obj,
224 :file => attachment,
223 :file => attachment,
225 :author => user,
224 :author => user,
226 :content_type => attachment.content_type)
225 :content_type => attachment.content_type)
227 end
226 end
228 end
227 end
229 end
228 end
230
229
231 # Adds To and Cc as watchers of the given object if the sender has the
230 # Adds To and Cc as watchers of the given object if the sender has the
232 # appropriate permission
231 # appropriate permission
233 def add_watchers(obj)
232 def add_watchers(obj)
234 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
233 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
235 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
234 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
236 unless addresses.empty?
235 unless addresses.empty?
237 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
236 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
238 watchers.each {|w| obj.add_watcher(w)}
237 watchers.each {|w| obj.add_watcher(w)}
239 end
238 end
240 end
239 end
241 end
240 end
242
241
243 def get_keyword(attr, options={})
242 def get_keyword(attr, options={})
244 @keywords ||= {}
243 @keywords ||= {}
245 if @keywords.has_key?(attr)
244 if @keywords.has_key?(attr)
246 @keywords[attr]
245 @keywords[attr]
247 else
246 else
248 @keywords[attr] = begin
247 @keywords[attr] = begin
249 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr}[ \t]*:[ \t]*(.+)\s*$/i, '')
248 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr}[ \t]*:[ \t]*(.+)\s*$/i, '')
250 $1.strip
249 $1.strip
251 elsif !@@handler_options[:issue][attr].blank?
250 elsif !@@handler_options[:issue][attr].blank?
252 @@handler_options[:issue][attr]
251 @@handler_options[:issue][attr]
253 end
252 end
254 end
253 end
255 end
254 end
256 end
255 end
257
256
258 # Returns the text/plain part of the email
257 # Returns the text/plain part of the email
259 # If not found (eg. HTML-only email), returns the body with tags removed
258 # If not found (eg. HTML-only email), returns the body with tags removed
260 def plain_text_body
259 def plain_text_body
261 return @plain_text_body unless @plain_text_body.nil?
260 return @plain_text_body unless @plain_text_body.nil?
262 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
261 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
263 if parts.empty?
262 if parts.empty?
264 parts << @email
263 parts << @email
265 end
264 end
266 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
265 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
267 if plain_text_part.nil?
266 if plain_text_part.nil?
268 # no text/plain part found, assuming html-only email
267 # no text/plain part found, assuming html-only email
269 # strip html tags and remove doctype directive
268 # strip html tags and remove doctype directive
270 @plain_text_body = strip_tags(@email.body.to_s)
269 @plain_text_body = strip_tags(@email.body.to_s)
271 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
270 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
272 else
271 else
273 @plain_text_body = plain_text_part.body.to_s
272 @plain_text_body = plain_text_part.body.to_s
274 end
273 end
275 @plain_text_body.strip!
274 @plain_text_body.strip!
276 @plain_text_body
275 @plain_text_body
277 end
276 end
278
277
279 def cleaned_up_text_body
278 def cleaned_up_text_body
280 cleanup_body(plain_text_body)
279 cleanup_body(plain_text_body)
281 end
280 end
282
281
283 def self.full_sanitizer
282 def self.full_sanitizer
284 @full_sanitizer ||= HTML::FullSanitizer.new
283 @full_sanitizer ||= HTML::FullSanitizer.new
285 end
284 end
286
285
287 # Creates a user account for the +email+ sender
286 # Creates a user account for the +email+ sender
288 def self.create_user_from_email(email)
287 def self.create_user_from_email(email)
289 addr = email.from_addrs.to_a.first
288 addr = email.from_addrs.to_a.first
290 if addr && !addr.spec.blank?
289 if addr && !addr.spec.blank?
291 user = User.new
290 user = User.new
292 user.mail = addr.spec
291 user.mail = addr.spec
293
292
294 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
293 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
295 user.firstname = names.shift
294 user.firstname = names.shift
296 user.lastname = names.join(' ')
295 user.lastname = names.join(' ')
297 user.lastname = '-' if user.lastname.blank?
296 user.lastname = '-' if user.lastname.blank?
298
297
299 user.login = user.mail
298 user.login = user.mail
300 user.password = ActiveSupport::SecureRandom.hex(5)
299 user.password = ActiveSupport::SecureRandom.hex(5)
301 user.language = Setting.default_language
300 user.language = Setting.default_language
302 user.save ? user : nil
301 user.save ? user : nil
303 end
302 end
304 end
303 end
305
304
306 private
305 private
307
306
308 # Removes the email body of text after the truncation configurations.
307 # Removes the email body of text after the truncation configurations.
309 def cleanup_body(body)
308 def cleanup_body(body)
310 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
309 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
311 unless delimiters.empty?
310 unless delimiters.empty?
312 regex = Regexp.new("^(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
311 regex = Regexp.new("^(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
313 body = body.gsub(regex, '')
312 body = body.gsub(regex, '')
314 end
313 end
315 body.strip
314 body.strip
316 end
315 end
317 end
316 end
@@ -1,345 +1,350
1 package Apache::Authn::Redmine;
1 package Apache::Authn::Redmine;
2
2
3 =head1 Apache::Authn::Redmine
3 =head1 Apache::Authn::Redmine
4
4
5 Redmine - a mod_perl module to authenticate webdav subversion users
5 Redmine - a mod_perl module to authenticate webdav subversion users
6 against redmine database
6 against redmine database
7
7
8 =head1 SYNOPSIS
8 =head1 SYNOPSIS
9
9
10 This module allow anonymous users to browse public project and
10 This module allow anonymous users to browse public project and
11 registred users to browse and commit their project. Authentication is
11 registred users to browse and commit their project. Authentication is
12 done against the redmine database or the LDAP configured in redmine.
12 done against the redmine database or the LDAP configured in redmine.
13
13
14 This method is far simpler than the one with pam_* and works with all
14 This method is far simpler than the one with pam_* and works with all
15 database without an hassle but you need to have apache/mod_perl on the
15 database without an hassle but you need to have apache/mod_perl on the
16 svn server.
16 svn server.
17
17
18 =head1 INSTALLATION
18 =head1 INSTALLATION
19
19
20 For this to automagically work, you need to have a recent reposman.rb
20 For this to automagically work, you need to have a recent reposman.rb
21 (after r860) and if you already use reposman, read the last section to
21 (after r860) and if you already use reposman, read the last section to
22 migrate.
22 migrate.
23
23
24 Sorry ruby users but you need some perl modules, at least mod_perl2,
24 Sorry ruby users but you need some perl modules, at least mod_perl2,
25 DBI and DBD::mysql (or the DBD driver for you database as it should
25 DBI and DBD::mysql (or the DBD driver for you database as it should
26 work on allmost all databases).
26 work on allmost all databases).
27
27
28 On debian/ubuntu you must do :
28 On debian/ubuntu you must do :
29
29
30 aptitude install libapache-dbi-perl libapache2-mod-perl2 libdbd-mysql-perl
30 aptitude install libapache-dbi-perl libapache2-mod-perl2 libdbd-mysql-perl
31
31
32 If your Redmine users use LDAP authentication, you will also need
32 If your Redmine users use LDAP authentication, you will also need
33 Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used):
33 Authen::Simple::LDAP (and IO::Socket::SSL if LDAPS is used):
34
34
35 aptitude install libauthen-simple-ldap-perl libio-socket-ssl-perl
35 aptitude install libauthen-simple-ldap-perl libio-socket-ssl-perl
36
36
37 =head1 CONFIGURATION
37 =head1 CONFIGURATION
38
38
39 ## This module has to be in your perl path
39 ## This module has to be in your perl path
40 ## eg: /usr/lib/perl5/Apache/Authn/Redmine.pm
40 ## eg: /usr/lib/perl5/Apache/Authn/Redmine.pm
41 PerlLoadModule Apache::Authn::Redmine
41 PerlLoadModule Apache::Authn::Redmine
42 <Location /svn>
42 <Location /svn>
43 DAV svn
43 DAV svn
44 SVNParentPath "/var/svn"
44 SVNParentPath "/var/svn"
45
45
46 AuthType Basic
46 AuthType Basic
47 AuthName redmine
47 AuthName redmine
48 Require valid-user
48 Require valid-user
49
49
50 PerlAccessHandler Apache::Authn::Redmine::access_handler
50 PerlAccessHandler Apache::Authn::Redmine::access_handler
51 PerlAuthenHandler Apache::Authn::Redmine::authen_handler
51 PerlAuthenHandler Apache::Authn::Redmine::authen_handler
52
52
53 ## for mysql
53 ## for mysql
54 RedmineDSN "DBI:mysql:database=databasename;host=my.db.server"
54 RedmineDSN "DBI:mysql:database=databasename;host=my.db.server"
55 ## for postgres
55 ## for postgres
56 # RedmineDSN "DBI:Pg:dbname=databasename;host=my.db.server"
56 # RedmineDSN "DBI:Pg:dbname=databasename;host=my.db.server"
57
57
58 RedmineDbUser "redmine"
58 RedmineDbUser "redmine"
59 RedmineDbPass "password"
59 RedmineDbPass "password"
60 ## Optional where clause (fulltext search would be slow and
60 ## Optional where clause (fulltext search would be slow and
61 ## database dependant).
61 ## database dependant).
62 # RedmineDbWhereClause "and members.role_id IN (1,2)"
62 # RedmineDbWhereClause "and members.role_id IN (1,2)"
63 ## Optional credentials cache size
63 ## Optional credentials cache size
64 # RedmineCacheCredsMax 50
64 # RedmineCacheCredsMax 50
65 </Location>
65 </Location>
66
66
67 To be able to browse repository inside redmine, you must add something
67 To be able to browse repository inside redmine, you must add something
68 like that :
68 like that :
69
69
70 <Location /svn-private>
70 <Location /svn-private>
71 DAV svn
71 DAV svn
72 SVNParentPath "/var/svn"
72 SVNParentPath "/var/svn"
73 Order deny,allow
73 Order deny,allow
74 Deny from all
74 Deny from all
75 # only allow reading orders
75 # only allow reading orders
76 <Limit GET PROPFIND OPTIONS REPORT>
76 <Limit GET PROPFIND OPTIONS REPORT>
77 Allow from redmine.server.ip
77 Allow from redmine.server.ip
78 </Limit>
78 </Limit>
79 </Location>
79 </Location>
80
80
81 and you will have to use this reposman.rb command line to create repository :
81 and you will have to use this reposman.rb command line to create repository :
82
82
83 reposman.rb --redmine my.redmine.server --svn-dir /var/svn --owner www-data -u http://svn.server/svn-private/
83 reposman.rb --redmine my.redmine.server --svn-dir /var/svn --owner www-data -u http://svn.server/svn-private/
84
84
85 =head1 MIGRATION FROM OLDER RELEASES
85 =head1 MIGRATION FROM OLDER RELEASES
86
86
87 If you use an older reposman.rb (r860 or before), you need to change
87 If you use an older reposman.rb (r860 or before), you need to change
88 rights on repositories to allow the apache user to read and write
88 rights on repositories to allow the apache user to read and write
89 S<them :>
89 S<them :>
90
90
91 sudo chown -R www-data /var/svn/*
91 sudo chown -R www-data /var/svn/*
92 sudo chmod -R u+w /var/svn/*
92 sudo chmod -R u+w /var/svn/*
93
93
94 And you need to upgrade at least reposman.rb (after r860).
94 And you need to upgrade at least reposman.rb (after r860).
95
95
96 =cut
96 =cut
97
97
98 use strict;
98 use strict;
99 use warnings FATAL => 'all', NONFATAL => 'redefine';
99 use warnings FATAL => 'all', NONFATAL => 'redefine';
100
100
101 use DBI;
101 use DBI;
102 use Digest::SHA1;
102 use Digest::SHA1;
103 # optional module for LDAP authentication
103 # optional module for LDAP authentication
104 my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1");
104 my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1");
105
105
106 use Apache2::Module;
106 use Apache2::Module;
107 use Apache2::Access;
107 use Apache2::Access;
108 use Apache2::ServerRec qw();
108 use Apache2::ServerRec qw();
109 use Apache2::RequestRec qw();
109 use Apache2::RequestRec qw();
110 use Apache2::RequestUtil qw();
110 use Apache2::RequestUtil qw();
111 use Apache2::Const qw(:common :override :cmd_how);
111 use Apache2::Const qw(:common :override :cmd_how);
112 use APR::Pool ();
112 use APR::Pool ();
113 use APR::Table ();
113 use APR::Table ();
114
114
115 # use Apache2::Directive qw();
115 # use Apache2::Directive qw();
116
116
117 my @directives = (
117 my @directives = (
118 {
118 {
119 name => 'RedmineDSN',
119 name => 'RedmineDSN',
120 req_override => OR_AUTHCFG,
120 req_override => OR_AUTHCFG,
121 args_how => TAKE1,
121 args_how => TAKE1,
122 errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
122 errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
123 },
123 },
124 {
124 {
125 name => 'RedmineDbUser',
125 name => 'RedmineDbUser',
126 req_override => OR_AUTHCFG,
126 req_override => OR_AUTHCFG,
127 args_how => TAKE1,
127 args_how => TAKE1,
128 },
128 },
129 {
129 {
130 name => 'RedmineDbPass',
130 name => 'RedmineDbPass',
131 req_override => OR_AUTHCFG,
131 req_override => OR_AUTHCFG,
132 args_how => TAKE1,
132 args_how => TAKE1,
133 },
133 },
134 {
134 {
135 name => 'RedmineDbWhereClause',
135 name => 'RedmineDbWhereClause',
136 req_override => OR_AUTHCFG,
136 req_override => OR_AUTHCFG,
137 args_how => TAKE1,
137 args_how => TAKE1,
138 },
138 },
139 {
139 {
140 name => 'RedmineCacheCredsMax',
140 name => 'RedmineCacheCredsMax',
141 req_override => OR_AUTHCFG,
141 req_override => OR_AUTHCFG,
142 args_how => TAKE1,
142 args_how => TAKE1,
143 errmsg => 'RedmineCacheCredsMax must be decimal number',
143 errmsg => 'RedmineCacheCredsMax must be decimal number',
144 },
144 },
145 );
145 );
146
146
147 sub RedmineDSN {
147 sub RedmineDSN {
148 my ($self, $parms, $arg) = @_;
148 my ($self, $parms, $arg) = @_;
149 $self->{RedmineDSN} = $arg;
149 $self->{RedmineDSN} = $arg;
150 my $query = "SELECT
150 my $query = "SELECT
151 hashed_password, auth_source_id, permissions
151 hashed_password, auth_source_id, permissions
152 FROM members, projects, users, roles, member_roles
152 FROM members, projects, users, roles, member_roles
153 WHERE
153 WHERE
154 projects.id=members.project_id
154 projects.id=members.project_id
155 AND member_roles.member_id=members.id
155 AND member_roles.member_id=members.id
156 AND users.id=members.user_id
156 AND users.id=members.user_id
157 AND roles.id=member_roles.role_id
157 AND roles.id=member_roles.role_id
158 AND users.status=1
158 AND users.status=1
159 AND login=?
159 AND login=?
160 AND identifier=? ";
160 AND identifier=? ";
161 $self->{RedmineQuery} = trim($query);
161 $self->{RedmineQuery} = trim($query);
162 }
162 }
163
163
164 sub RedmineDbUser { set_val('RedmineDbUser', @_); }
164 sub RedmineDbUser { set_val('RedmineDbUser', @_); }
165 sub RedmineDbPass { set_val('RedmineDbPass', @_); }
165 sub RedmineDbPass { set_val('RedmineDbPass', @_); }
166 sub RedmineDbWhereClause {
166 sub RedmineDbWhereClause {
167 my ($self, $parms, $arg) = @_;
167 my ($self, $parms, $arg) = @_;
168 $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." ");
168 $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." ");
169 }
169 }
170
170
171 sub RedmineCacheCredsMax {
171 sub RedmineCacheCredsMax {
172 my ($self, $parms, $arg) = @_;
172 my ($self, $parms, $arg) = @_;
173 if ($arg) {
173 if ($arg) {
174 $self->{RedmineCachePool} = APR::Pool->new;
174 $self->{RedmineCachePool} = APR::Pool->new;
175 $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg);
175 $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg);
176 $self->{RedmineCacheCredsCount} = 0;
176 $self->{RedmineCacheCredsCount} = 0;
177 $self->{RedmineCacheCredsMax} = $arg;
177 $self->{RedmineCacheCredsMax} = $arg;
178 }
178 }
179 }
179 }
180
180
181 sub trim {
181 sub trim {
182 my $string = shift;
182 my $string = shift;
183 $string =~ s/\s{2,}/ /g;
183 $string =~ s/\s{2,}/ /g;
184 return $string;
184 return $string;
185 }
185 }
186
186
187 sub set_val {
187 sub set_val {
188 my ($key, $self, $parms, $arg) = @_;
188 my ($key, $self, $parms, $arg) = @_;
189 $self->{$key} = $arg;
189 $self->{$key} = $arg;
190 }
190 }
191
191
192 Apache2::Module::add(__PACKAGE__, \@directives);
192 Apache2::Module::add(__PACKAGE__, \@directives);
193
193
194
194
195 my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
195 my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
196
196
197 sub access_handler {
197 sub access_handler {
198 my $r = shift;
198 my $r = shift;
199
199
200 unless ($r->some_auth_required) {
200 unless ($r->some_auth_required) {
201 $r->log_reason("No authentication has been configured");
201 $r->log_reason("No authentication has been configured");
202 return FORBIDDEN;
202 return FORBIDDEN;
203 }
203 }
204
204
205 my $method = $r->method;
205 my $method = $r->method;
206 return OK unless defined $read_only_methods{$method};
206 return OK unless defined $read_only_methods{$method};
207
207
208 my $project_id = get_project_identifier($r);
208 my $project_id = get_project_identifier($r);
209
209
210 $r->set_handlers(PerlAuthenHandler => [\&OK])
210 $r->set_handlers(PerlAuthenHandler => [\&OK])
211 if is_public_project($project_id, $r);
211 if is_public_project($project_id, $r);
212
212
213 return OK
213 return OK
214 }
214 }
215
215
216 sub authen_handler {
216 sub authen_handler {
217 my $r = shift;
217 my $r = shift;
218
218
219 my ($res, $redmine_pass) = $r->get_basic_auth_pw();
219 my ($res, $redmine_pass) = $r->get_basic_auth_pw();
220 return $res unless $res == OK;
220 return $res unless $res == OK;
221
221
222 if (is_member($r->user, $redmine_pass, $r)) {
222 if (is_member($r->user, $redmine_pass, $r)) {
223 return OK;
223 return OK;
224 } else {
224 } else {
225 $r->note_auth_failure();
225 $r->note_auth_failure();
226 return AUTH_REQUIRED;
226 return AUTH_REQUIRED;
227 }
227 }
228 }
228 }
229
229
230 sub is_public_project {
230 sub is_public_project {
231 my $project_id = shift;
231 my $project_id = shift;
232 my $r = shift;
232 my $r = shift;
233
233
234 my $dbh = connect_database($r);
234 my $dbh = connect_database($r);
235 my $sth = $dbh->prepare(
235 my $sth = $dbh->prepare(
236 "SELECT * FROM projects WHERE projects.identifier=? and projects.is_public=true;"
236 "SELECT is_public FROM projects WHERE projects.identifier = ?;"
237 );
237 );
238
238
239 $sth->execute($project_id);
239 $sth->execute($project_id);
240 my $ret = $sth->fetchrow_array ? 1 : 0;
240 my $ret = 0;
241 if (my @row = $sth->fetchrow_array) {
242 if ($row[0] eq "1" || $row[0] eq "t") {
243 $ret = 1;
244 }
245 }
241 $sth->finish();
246 $sth->finish();
242 $dbh->disconnect();
247 $dbh->disconnect();
243
248
244 $ret;
249 $ret;
245 }
250 }
246
251
247 # perhaps we should use repository right (other read right) to check public access.
252 # perhaps we should use repository right (other read right) to check public access.
248 # it could be faster BUT it doesn't work for the moment.
253 # it could be faster BUT it doesn't work for the moment.
249 # sub is_public_project_by_file {
254 # sub is_public_project_by_file {
250 # my $project_id = shift;
255 # my $project_id = shift;
251 # my $r = shift;
256 # my $r = shift;
252
257
253 # my $tree = Apache2::Directive::conftree();
258 # my $tree = Apache2::Directive::conftree();
254 # my $node = $tree->lookup('Location', $r->location);
259 # my $node = $tree->lookup('Location', $r->location);
255 # my $hash = $node->as_hash;
260 # my $hash = $node->as_hash;
256
261
257 # my $svnparentpath = $hash->{SVNParentPath};
262 # my $svnparentpath = $hash->{SVNParentPath};
258 # my $repos_path = $svnparentpath . "/" . $project_id;
263 # my $repos_path = $svnparentpath . "/" . $project_id;
259 # return 1 if (stat($repos_path))[2] & 00007;
264 # return 1 if (stat($repos_path))[2] & 00007;
260 # }
265 # }
261
266
262 sub is_member {
267 sub is_member {
263 my $redmine_user = shift;
268 my $redmine_user = shift;
264 my $redmine_pass = shift;
269 my $redmine_pass = shift;
265 my $r = shift;
270 my $r = shift;
266
271
267 my $dbh = connect_database($r);
272 my $dbh = connect_database($r);
268 my $project_id = get_project_identifier($r);
273 my $project_id = get_project_identifier($r);
269
274
270 my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
275 my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
271
276
272 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
277 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
273 my $usrprojpass;
278 my $usrprojpass;
274 if ($cfg->{RedmineCacheCredsMax}) {
279 if ($cfg->{RedmineCacheCredsMax}) {
275 $usrprojpass = $cfg->{RedmineCacheCreds}->get($redmine_user.":".$project_id);
280 $usrprojpass = $cfg->{RedmineCacheCreds}->get($redmine_user.":".$project_id);
276 return 1 if (defined $usrprojpass and ($usrprojpass eq $pass_digest));
281 return 1 if (defined $usrprojpass and ($usrprojpass eq $pass_digest));
277 }
282 }
278 my $query = $cfg->{RedmineQuery};
283 my $query = $cfg->{RedmineQuery};
279 my $sth = $dbh->prepare($query);
284 my $sth = $dbh->prepare($query);
280 $sth->execute($redmine_user, $project_id);
285 $sth->execute($redmine_user, $project_id);
281
286
282 my $ret;
287 my $ret;
283 while (my ($hashed_password, $auth_source_id, $permissions) = $sth->fetchrow_array) {
288 while (my ($hashed_password, $auth_source_id, $permissions) = $sth->fetchrow_array) {
284
289
285 unless ($auth_source_id) {
290 unless ($auth_source_id) {
286 my $method = $r->method;
291 my $method = $r->method;
287 if ($hashed_password eq $pass_digest && ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/) ) {
292 if ($hashed_password eq $pass_digest && ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/) || $permissions =~ /:commit_access/) ) {
288 $ret = 1;
293 $ret = 1;
289 last;
294 last;
290 }
295 }
291 } elsif ($CanUseLDAPAuth) {
296 } elsif ($CanUseLDAPAuth) {
292 my $sthldap = $dbh->prepare(
297 my $sthldap = $dbh->prepare(
293 "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;"
298 "SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;"
294 );
299 );
295 $sthldap->execute($auth_source_id);
300 $sthldap->execute($auth_source_id);
296 while (my @rowldap = $sthldap->fetchrow_array) {
301 while (my @rowldap = $sthldap->fetchrow_array) {
297 my $ldap = Authen::Simple::LDAP->new(
302 my $ldap = Authen::Simple::LDAP->new(
298 host => ($rowldap[2] == 1 || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
303 host => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
299 port => $rowldap[1],
304 port => $rowldap[1],
300 basedn => $rowldap[5],
305 basedn => $rowldap[5],
301 binddn => $rowldap[3] ? $rowldap[3] : "",
306 binddn => $rowldap[3] ? $rowldap[3] : "",
302 bindpw => $rowldap[4] ? $rowldap[4] : "",
307 bindpw => $rowldap[4] ? $rowldap[4] : "",
303 filter => "(".$rowldap[6]."=%s)"
308 filter => "(".$rowldap[6]."=%s)"
304 );
309 );
305 $ret = 1 if ($ldap->authenticate($redmine_user, $redmine_pass));
310 $ret = 1 if ($ldap->authenticate($redmine_user, $redmine_pass));
306 }
311 }
307 $sthldap->finish();
312 $sthldap->finish();
308 }
313 }
309 }
314 }
310 $sth->finish();
315 $sth->finish();
311 $dbh->disconnect();
316 $dbh->disconnect();
312
317
313 if ($cfg->{RedmineCacheCredsMax} and $ret) {
318 if ($cfg->{RedmineCacheCredsMax} and $ret) {
314 if (defined $usrprojpass) {
319 if (defined $usrprojpass) {
315 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest);
320 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest);
316 } else {
321 } else {
317 if ($cfg->{RedmineCacheCredsCount} < $cfg->{RedmineCacheCredsMax}) {
322 if ($cfg->{RedmineCacheCredsCount} < $cfg->{RedmineCacheCredsMax}) {
318 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest);
323 $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest);
319 $cfg->{RedmineCacheCredsCount}++;
324 $cfg->{RedmineCacheCredsCount}++;
320 } else {
325 } else {
321 $cfg->{RedmineCacheCreds}->clear();
326 $cfg->{RedmineCacheCreds}->clear();
322 $cfg->{RedmineCacheCredsCount} = 0;
327 $cfg->{RedmineCacheCredsCount} = 0;
323 }
328 }
324 }
329 }
325 }
330 }
326
331
327 $ret;
332 $ret;
328 }
333 }
329
334
330 sub get_project_identifier {
335 sub get_project_identifier {
331 my $r = shift;
336 my $r = shift;
332
337
333 my $location = $r->location;
338 my $location = $r->location;
334 my ($identifier) = $r->uri =~ m{$location/*([^/]+)};
339 my ($identifier) = $r->uri =~ m{$location/*([^/]+)};
335 $identifier;
340 $identifier;
336 }
341 }
337
342
338 sub connect_database {
343 sub connect_database {
339 my $r = shift;
344 my $r = shift;
340
345
341 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
346 my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config);
342 return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass});
347 return DBI->connect($cfg->{RedmineDSN}, $cfg->{RedmineDbUser}, $cfg->{RedmineDbPass});
343 }
348 }
344
349
345 1;
350 1;
General Comments 0
You need to be logged in to leave comments. Login now