Feature #43023 » 0001-Implement-OAuth-2.0-XOAUTH2-support-for-incoming-IMA.patch
| app/controllers/email_oauth_controller.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require 'net/http' |
|
| 21 |
require 'uri' |
|
| 22 |
require 'json' |
|
| 23 | ||
| 24 |
class EmailOauthController < ApplicationController |
|
| 25 |
layout 'admin' |
|
| 26 |
before_action :require_admin |
|
| 27 | ||
| 28 |
def index |
|
| 29 |
@tokens = EmailOauthToken.order(:email) |
|
| 30 |
end |
|
| 31 | ||
| 32 |
def new |
|
| 33 |
@token = EmailOauthToken.new |
|
| 34 |
if Setting.email_oauth_providers.empty? |
|
| 35 |
flash[:error] = l(:text_email_oauth_providers_not_configured) |
|
| 36 |
redirect_to email_oauth_index_path |
|
| 37 |
end |
|
| 38 |
end |
|
| 39 | ||
| 40 |
def initiate |
|
| 41 |
email = params[:email_oauth_token][:email] |
|
| 42 |
provider = params[:email_oauth_token][:provider] |
|
| 43 | ||
| 44 |
provider_config = get_provider_config(provider) |
|
| 45 |
if provider_config.blank? |
|
| 46 |
flash[:error] = l(:error_oauth_authentication_failed, message: "Provider '#{provider}' is not configured in configuration.yml.")
|
|
| 47 |
redirect_to new_email_oauth_path and return |
|
| 48 |
end |
|
| 49 | ||
| 50 |
# Build authorization URL |
|
| 51 |
auth_uri = build_auth_uri(provider, provider_config, email) |
|
| 52 | ||
| 53 |
# Store state in session to prevent CSRF / verify callback |
|
| 54 |
session[:email_oauth_state] = SecureRandom.hex(16) |
|
| 55 |
session[:email_oauth_email] = email |
|
| 56 |
session[:email_oauth_provider] = provider |
|
| 57 | ||
| 58 |
redirect_to auth_uri, allow_other_host: true |
|
| 59 |
end |
|
| 60 | ||
| 61 |
def callback |
|
| 62 |
state = params[:state] |
|
| 63 |
code = params[:code] |
|
| 64 |
error = params[:error] |
|
| 65 | ||
| 66 |
if error.present? |
|
| 67 |
flash[:error] = l(:error_oauth_authentication_failed, message: error) |
|
| 68 |
redirect_to email_oauth_index_path and return |
|
| 69 |
end |
|
| 70 | ||
| 71 |
unless Rails.env.development? |
|
| 72 |
if state.blank? || state != session[:email_oauth_state] |
|
| 73 |
flash[:error] = l(:error_oauth_authentication_failed, message: "OAuth verification state mismatch. Potential CSRF request.") |
|
| 74 |
redirect_to email_oauth_index_path and return |
|
| 75 |
end |
|
| 76 |
end |
|
| 77 | ||
| 78 |
email = session.delete(:email_oauth_email) |
|
| 79 |
provider = session.delete(:email_oauth_provider) |
|
| 80 |
session.delete(:email_oauth_state) |
|
| 81 | ||
| 82 |
provider_config = get_provider_config(provider) |
|
| 83 |
if provider_config.blank? || email.blank? |
|
| 84 |
flash[:error] = l(:error_oauth_authentication_failed, message: "Invalid session state or missing provider configuration.") |
|
| 85 |
redirect_to email_oauth_index_path and return |
|
| 86 |
end |
|
| 87 | ||
| 88 |
# Exchange authorization code for tokens |
|
| 89 |
exchange_and_save_tokens(provider, provider_config, email, code) |
|
| 90 |
end |
|
| 91 | ||
| 92 |
def destroy |
|
| 93 |
@token = EmailOauthToken.find(params[:id]) |
|
| 94 |
if @token.destroy |
|
| 95 |
flash[:notice] = l(:notice_successful_delete) |
|
| 96 |
else |
|
| 97 |
flash[:error] = l(:error_oauth_authentication_failed, message: "Failed to delete OAuth token mapping.") |
|
| 98 |
end |
|
| 99 |
redirect_to email_oauth_index_path |
|
| 100 |
end |
|
| 101 | ||
| 102 |
private |
|
| 103 | ||
| 104 |
def email_oauth_config |
|
| 105 |
Redmine::Configuration['email_oauth'] |
|
| 106 |
end |
|
| 107 | ||
| 108 |
def get_provider_config(provider) |
|
| 109 |
return nil if email_oauth_config.blank? |
|
| 110 | ||
| 111 |
email_oauth_config['providers']&.[] (provider.to_s) |
|
| 112 |
end |
|
| 113 | ||
| 114 |
def build_auth_uri(provider, config, email) |
|
| 115 |
redirect_uri = callback_email_oauth_url |
|
| 116 |
state = session[:email_oauth_state] |
|
| 117 | ||
| 118 |
case provider.to_s |
|
| 119 |
when 'google' |
|
| 120 |
# Google Workspace OAuth authorize URI |
|
| 121 |
uri = URI("https://accounts.google.com/o/oauth2/v2/auth")
|
|
| 122 |
uri.query = URI.encode_www_form({
|
|
| 123 |
client_id: config['client_id'], |
|
| 124 |
redirect_uri: redirect_uri, |
|
| 125 |
response_type: 'code', |
|
| 126 |
scope: 'https://mail.google.com/', |
|
| 127 |
state: state, |
|
| 128 |
login_hint: email, |
|
| 129 |
access_type: 'offline', |
|
| 130 |
prompt: 'consent' |
|
| 131 |
}) |
|
| 132 |
uri.to_s |
|
| 133 |
when 'microsoft' |
|
| 134 |
# Microsoft Azure AD OAuth authorize URI |
|
| 135 |
tenant = config['tenant'] || 'common' |
|
| 136 |
uri = URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/authorize")
|
|
| 137 |
uri.query = URI.encode_www_form({
|
|
| 138 |
client_id: config['client_id'], |
|
| 139 |
redirect_uri: redirect_uri, |
|
| 140 |
response_type: 'code', |
|
| 141 |
scope: 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access', |
|
| 142 |
state: state, |
|
| 143 |
login_hint: email, |
|
| 144 |
prompt: 'consent' |
|
| 145 |
}) |
|
| 146 |
uri.to_s |
|
| 147 |
when 'custom' |
|
| 148 |
# Custom provider dynamic authorize URL |
|
| 149 |
uri = URI(config['authorize_url']) |
|
| 150 |
params = {
|
|
| 151 |
client_id: config['client_id'], |
|
| 152 |
redirect_uri: redirect_uri, |
|
| 153 |
response_type: 'code', |
|
| 154 |
state: state |
|
| 155 |
} |
|
| 156 |
params[:scope] = config['scope'] if config['scope'].present? |
|
| 157 |
params[:login_hint] = email if email.present? |
|
| 158 |
uri.query = URI.encode_www_form(params) |
|
| 159 |
uri.to_s |
|
| 160 |
else |
|
| 161 |
raise "Unsupported OAuth provider: #{provider}"
|
|
| 162 |
end |
|
| 163 |
end |
|
| 164 | ||
| 165 |
def exchange_and_save_tokens(provider, config, email, code) |
|
| 166 |
token_uri = case provider.to_s |
|
| 167 |
when 'google' |
|
| 168 |
URI("https://oauth2.googleapis.com/token")
|
|
| 169 |
when 'microsoft' |
|
| 170 |
tenant = config['tenant'] || 'common' |
|
| 171 |
URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token")
|
|
| 172 |
when 'custom' |
|
| 173 |
URI(config['token_url']) |
|
| 174 |
end |
|
| 175 | ||
| 176 |
payload = {
|
|
| 177 |
client_id: config['client_id'], |
|
| 178 |
client_secret: config['client_secret'], |
|
| 179 |
code: code, |
|
| 180 |
redirect_uri: callback_email_oauth_url, |
|
| 181 |
grant_type: 'authorization_code' |
|
| 182 |
} |
|
| 183 | ||
| 184 |
res = Net::HTTP.post_form(token_uri, payload) |
|
| 185 |
if res.is_a?(Net::HTTPSuccess) |
|
| 186 |
data = JSON.parse(res.body) |
|
| 187 | ||
| 188 |
expires_in = data['expires_in'].to_i.seconds |
|
| 189 |
expires_at = Time.now + expires_in |
|
| 190 | ||
| 191 |
# Find or initialize token mapping |
|
| 192 |
token_record = EmailOauthToken.find_or_initialize_by(email: email) |
|
| 193 |
token_record.provider = provider |
|
| 194 |
token_record.access_token = data['access_token'] |
|
| 195 |
token_record.refresh_token = data['refresh_token'] if data['refresh_token'].present? |
|
| 196 |
token_record.expires_at = expires_at |
|
| 197 |
token_record.is_valid = true |
|
| 198 |
token_record.last_error = nil |
|
| 199 | ||
| 200 |
if token_record.save |
|
| 201 |
flash[:notice] = l(:notice_successful_create) |
|
| 202 |
else |
|
| 203 |
flash[:error] = l(:error_oauth_authentication_failed, message: token_record.errors.full_messages.join(', '))
|
|
| 204 |
end |
|
| 205 |
else |
|
| 206 |
flash[:error] = l(:error_oauth_authentication_failed, message: "[#{res.code}] #{res.body}")
|
|
| 207 |
end |
|
| 208 | ||
| 209 |
redirect_to email_oauth_index_path |
|
| 210 |
end |
|
| 211 |
end |
|
| app/models/email_oauth_token.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
class EmailOauthToken < ApplicationRecord |
|
| 21 |
include Redmine::Ciphering |
|
| 22 | ||
| 23 |
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
| 24 |
validates :provider, presence: true, inclusion: { in: %w[microsoft google custom] }
|
|
| 25 |
validates :refresh_token, presence: true |
|
| 26 |
validates :expires_at, presence: true |
|
| 27 | ||
| 28 |
def access_token |
|
| 29 |
read_ciphered_attribute(:access_token) |
|
| 30 |
end |
|
| 31 | ||
| 32 |
def access_token=(arg) |
|
| 33 |
write_ciphered_attribute(:access_token, arg) |
|
| 34 |
end |
|
| 35 | ||
| 36 |
def refresh_token |
|
| 37 |
read_ciphered_attribute(:refresh_token) |
|
| 38 |
end |
|
| 39 | ||
| 40 |
def refresh_token=(arg) |
|
| 41 |
write_ciphered_attribute(:refresh_token, arg) |
|
| 42 |
end |
|
| 43 | ||
| 44 |
# Check if the access token has expired |
|
| 45 |
def expired? |
|
| 46 |
expires_at.nil? || expires_at <= Time.now |
|
| 47 |
end |
|
| 48 | ||
| 49 |
# Check if a refresh is needed (e.g., if expired or expiring in the next 5 minutes) |
|
| 50 |
def refresh_needed? |
|
| 51 |
expired? || expires_at <= 5.minutes.from_now |
|
| 52 |
end |
|
| 53 |
end |
|
| app/models/mailer.rb | ||
|---|---|---|
| 560 | 560 |
:subject => l(:mail_subject_test_email) |
| 561 | 561 |
end |
| 562 | 562 | |
| 563 |
# Notifies active administrators when an OAuth token refresh fails in background. |
|
| 564 |
def self.deliver_email_oauth_refresh_failure(token, error_message) |
|
| 565 |
users = User.active.where(admin: true) |
|
| 566 |
users.each do |user| |
|
| 567 |
email_oauth_refresh_failure(user, token, error_message).deliver_later |
|
| 568 |
end |
|
| 569 |
end |
|
| 570 | ||
| 571 |
# Builds the OAuth refresh failure email. |
|
| 572 |
def email_oauth_refresh_failure(user, token, error_message) |
|
| 573 |
@user = user |
|
| 574 |
@token = token |
|
| 575 |
@error_message = error_message |
|
| 576 |
@oauth_settings_url = url_for(controller: 'email_oauth', action: 'index') |
|
| 577 |
mail to: user.mail, |
|
| 578 |
subject: "[#{Setting.app_title}] OAuth 2.0 Email Account Authentication Failure"
|
|
| 579 |
end |
|
| 580 | ||
| 563 | 581 |
# Send a test email to user. Will raise error that may occur during delivery. |
| 564 | 582 |
# |
| 565 | 583 |
# Exemple: |
| app/models/setting.rb | ||
|---|---|---|
| 375 | 375 |
end.freeze |
| 376 | 376 |
end |
| 377 | 377 | |
| 378 |
# Returns an array containing the names of configured OAuth email providers in configuration.yml |
|
| 379 |
def self.email_oauth_providers |
|
| 380 |
config = Redmine::Configuration['email_oauth'] |
|
| 381 |
config ? (config['providers'] || {}).keys : []
|
|
| 382 |
end |
|
| 383 | ||
| 378 | 384 |
private |
| 379 | 385 | |
| 380 | 386 |
def force_utf8_strings(arg) |
| app/views/admin/index.html.erb | ||
|---|---|---|
| 1 | 1 |
<h2><%=l(:label_administration)%></h2> |
| 2 | 2 | |
| 3 |
<% if EmailOauthToken.where(is_valid: false).any? %> |
|
| 4 |
<div class="flash error"> |
|
| 5 |
<strong><%= l(:label_warning) %>:</strong> |
|
| 6 |
One or more configured OAuth 2.0 incoming email accounts have failed to refresh and are currently inactive. |
|
| 7 |
<%= link_to "Review OAuth status", email_oauth_index_path %> |
|
| 8 |
</div> |
|
| 9 |
<% end %> |
|
| 10 | ||
| 3 | 11 |
<div id="admin-index"> |
| 4 | 12 |
<%= render :partial => 'no_data' if @no_configuration_data %> |
| 5 | 13 |
<%= render :partial => 'menu' %> |
| app/views/email_oauth/index.html.erb | ||
|---|---|---|
| 1 |
<div class="contextual"> |
|
| 2 |
<%= link_to sprite_icon('add', l(:button_add)), new_email_oauth_path, class: 'icon icon-add' %>
|
|
| 3 |
</div> |
|
| 4 | ||
| 5 |
<%= title l(:label_oauth_authentication) %> |
|
| 6 | ||
| 7 |
<% if @tokens.empty? %> |
|
| 8 |
<p class="nodata"><%= l(:label_no_data) %></p> |
|
| 9 |
<% else %> |
|
| 10 |
<table class="list"> |
|
| 11 |
<thead> |
|
| 12 |
<tr> |
|
| 13 |
<th><%= l(:field_mail) %></th> |
|
| 14 |
<th><%= l(:label_provider) %></th> |
|
| 15 |
<th><%= l(:field_status) %></th> |
|
| 16 |
<th><%= l(:label_expires_at) %></th> |
|
| 17 |
<th><%= l(:field_updated_on) %></th> |
|
| 18 |
<th></th> |
|
| 19 |
</tr> |
|
| 20 |
</thead> |
|
| 21 |
<tbody> |
|
| 22 |
<% @tokens.each do |token| %> |
|
| 23 |
<tr class="<%= cycle('odd', 'even') %>">
|
|
| 24 |
<td class="name"><strong><%= token.email %></strong></td> |
|
| 25 |
<td><%= token.provider.to_s.capitalize %></td> |
|
| 26 |
<td> |
|
| 27 |
<% if token.is_valid? %> |
|
| 28 |
<%= content_tag(:span, sprite_icon("checked"), :class => 'icon-only icon-checked') %>
|
|
| 29 |
<% else %> |
|
| 30 |
<span class="icon icon-warning"> |
|
| 31 |
<%= token.last_error %> |
|
| 32 |
</span> |
|
| 33 |
<% end %> |
|
| 34 |
</td> |
|
| 35 |
<td><%= format_time(token.expires_at) %></td> |
|
| 36 |
<td><%= format_time(token.updated_at) %></td> |
|
| 37 |
<td class="buttons"> |
|
| 38 |
<%= delete_link destroy_email_oauth_path(token) %> |
|
| 39 |
</td> |
|
| 40 |
</tr> |
|
| 41 |
<% end %> |
|
| 42 |
</tbody> |
|
| 43 |
</table> |
|
| 44 |
<% end %> |
|
| app/views/email_oauth/new.html.erb | ||
|---|---|---|
| 1 |
<%= title [l(:label_oauth_authentication), email_oauth_index_path], l(:button_add) %> |
|
| 2 | ||
| 3 |
<%= labelled_form_for @token, :as => :email_oauth_token, :url => initiate_email_oauth_path, :html => {:id => 'email_oauth_form'} do |f| %>
|
|
| 4 |
<div class="box tabular"> |
|
| 5 |
<p> |
|
| 6 |
<%= f.text_field :email, :required => true, :size => 40, :placeholder => "redmine@example.net" %> |
|
| 7 |
<em class="info"><%= l(:text_oauth_email_username_info_html) %></em> |
|
| 8 |
</p> |
|
| 9 |
<p> |
|
| 10 |
<%= f.select :provider, Setting.email_oauth_providers.map {|p| [p.capitalize, p]}, :required => true, :label => l(:label_provider) %>
|
|
| 11 |
</p> |
|
| 12 |
</div> |
|
| 13 |
<%= submit_tag t('doorkeeper.applications.buttons.authorize') %>
|
|
| 14 |
<% end %> |
|
| app/views/mailer/email_oauth_refresh_failure.html.erb | ||
|---|---|---|
| 1 |
<p>Hello <%= @user.name %>,</p> |
|
| 2 | ||
| 3 |
<p>An error occurred while attempting to refresh the OAuth 2.0 access token for the following incoming email account:</p> |
|
| 4 | ||
| 5 |
<ul> |
|
| 6 |
<li><strong>Email Account:</strong> <%= @token.email %></li> |
|
| 7 |
<li><strong>Provider:</strong> <%= @token.provider.capitalize %></li> |
|
| 8 |
<li><strong>Error Message:</strong> <%= @error_message %></li> |
|
| 9 |
<li><strong>Failed At:</strong> <%= Time.now %></li> |
|
| 10 |
</ul> |
|
| 11 | ||
| 12 |
<p>As a result, Redmine is currently unable to fetch and process incoming emails from this mailbox.</p> |
|
| 13 | ||
| 14 |
<p>Please log in to Redmine as an administrator, review the error, and re-authorize the account:</p> |
|
| 15 | ||
| 16 |
<p><%= link_to l(:label_manage_oauth_emails), @oauth_settings_url %></p> |
|
| app/views/mailer/email_oauth_refresh_failure.text.erb | ||
|---|---|---|
| 1 |
Hello <%= @user.name %>, |
|
| 2 | ||
| 3 |
An error occurred while attempting to refresh the OAuth 2.0 access token for the following incoming email account: |
|
| 4 | ||
| 5 |
Email Account: <%= @token.email %> |
|
| 6 |
Provider: <%= @token.provider.capitalize %> |
|
| 7 |
Error Message: <%= @error_message %> |
|
| 8 |
Failed At: <%= Time.now %> |
|
| 9 | ||
| 10 |
As a result, Redmine is currently unable to fetch and process incoming emails from this mailbox. |
|
| 11 | ||
| 12 |
Please log in to Redmine as an administrator, review the error, and re-authorize the account: |
|
| 13 | ||
| 14 |
<%= @oauth_settings_url %> |
|
| app/views/settings/_mail_handler.html.erb | ||
|---|---|---|
| 32 | 32 |
</p> |
| 33 | 33 |
</div> |
| 34 | 34 | |
| 35 |
<div class="box tabular settings"> |
|
| 36 |
<h3><%= l(:label_oauth_authentication) %></h3> |
|
| 37 |
<% if Setting.email_oauth_providers.empty? %> |
|
| 38 |
<div class="nodata"> |
|
| 39 |
<%= simple_format(l(:text_email_oauth_providers_not_configured)) %> |
|
| 40 |
</div> |
|
| 41 |
<% else %> |
|
| 42 |
<fieldset class="box settings"> |
|
| 43 |
<legend><%= l(:setting_enabled_oauth_providers) %></legend> |
|
| 44 |
<ul> |
|
| 45 |
<% Setting.email_oauth_providers.each do |provider| %> |
|
| 46 |
<li><span><%= provider.capitalize %></span></li> |
|
| 47 |
<% end %> |
|
| 48 |
</ul> |
|
| 49 |
</fieldset> |
|
| 50 |
<%= link_to l(:label_manage_oauth_emails), email_oauth_index_path, class: 'icon icon-server-authentication' %> |
|
| 51 |
<% end %> |
|
| 52 |
</div> |
|
| 53 | ||
| 35 | 54 |
<%= submit_tag l(:button_save) %> |
| 36 | 55 | |
| 37 | 56 |
<% end %> |
| config/locales/en.yml | ||
|---|---|---|
| 540 | 540 |
setting_related_issues_default_columns: Related and sub issues list defaults |
| 541 | 541 |
setting_display_related_issues_table_headers: Show table headers |
| 542 | 542 |
setting_reactions_enabled: Enable reactions |
| 543 |
setting_enabled_oauth_providers: Enabled OAuth providers |
|
| 543 | 544 | |
| 544 | 545 |
permission_add_project: Create project |
| 545 | 546 |
permission_add_subprojects: Create subprojects |
| ... | ... | |
| 1004 | 1005 |
label_chronological_order: In chronological order |
| 1005 | 1006 |
label_reverse_chronological_order: In reverse chronological order |
| 1006 | 1007 |
label_incoming_emails: Incoming emails |
| 1008 |
label_oauth_authentication: OAuth 2.0 Authentication |
|
| 1009 |
label_manage_oauth_emails: Manage OAuth 2.0 Email Accounts |
|
| 1007 | 1010 |
label_generate_key: Generate a key |
| 1008 | 1011 |
label_issue_watchers: Watchers |
| 1009 | 1012 |
label_message_watchers: Watchers |
| ... | ... | |
| 1197 | 1200 |
label_enable_default_issue_due_date_offset: Enable default due date for new issues |
| 1198 | 1201 |
setting_default_issue_due_date_offset: Default due date for new issues |
| 1199 | 1202 |
label_days_relative_to_today: days relative to today |
| 1203 |
label_provider: Provider |
|
| 1204 |
label_expires_at: Expires at |
|
| 1200 | 1205 | |
| 1201 | 1206 |
webhook_event_created: "%{object_name} created"
|
| 1202 | 1207 |
webhook_event_updated: "%{object_name} updated"
|
| ... | ... | |
| 1399 | 1404 |
text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again. |
| 1400 | 1405 |
text_oauth_implicit_permissions: View your name, login and primary email address |
| 1401 | 1406 |
text_oauth_info_scopes: Select the scopes this application may request. The application will not be allowed to do more than what is selected here. It will also always be limited by the roles and project memberships of the user who authorized it. |
| 1407 |
text_email_oauth_providers_not_configured: "Email OAuth providers are not configured.\nPlease configure them in config/configuration.yml and restart the application to enable them." |
|
| 1408 |
text_oauth_email_username_info_html: Must exactly match the <code>username</code> parameter used in the background IMAP fetch job. |
|
| 1409 |
error_oauth_authentication_failed: "OAuth authentication failed: %{message}"
|
|
| 1402 | 1410 | |
| 1403 | 1411 |
default_role_manager: Manager |
| 1404 | 1412 |
default_role_developer: Developer |
| config/routes.rb | ||
|---|---|---|
| 377 | 377 |
get 'mail_handler', :to => 'mail_handler#new' |
| 378 | 378 |
post 'mail_handler', :to => 'mail_handler#index' |
| 379 | 379 | |
| 380 |
# OAuth incoming emails configuration |
|
| 381 |
get 'admin/mail_handler/oauth', :to => 'email_oauth#index', :as => 'email_oauth_index' |
|
| 382 |
get 'admin/mail_handler/oauth/new', :to => 'email_oauth#new', :as => 'new_email_oauth' |
|
| 383 |
post 'admin/mail_handler/oauth/initiate', :to => 'email_oauth#initiate', :as => 'initiate_email_oauth' |
|
| 384 |
get 'admin/mail_handler/oauth/callback', :to => 'email_oauth#callback', :as => 'callback_email_oauth' |
|
| 385 |
delete 'admin/mail_handler/oauth/:id', :to => 'email_oauth#destroy', :as => 'destroy_email_oauth' |
|
| 386 | ||
| 380 | 387 |
get 'admin', :to => 'admin#index' |
| 381 | 388 |
get 'admin/projects', :to => 'admin#projects' |
| 382 | 389 |
get 'admin/plugins', :to => 'admin#plugins' |
| db/migrate/20260702055909_create_email_oauth_tokens.rb | ||
|---|---|---|
| 1 |
class CreateEmailOauthTokens < ActiveRecord::Migration[8.1] |
|
| 2 |
def change |
|
| 3 |
create_table :email_oauth_tokens do |t| |
|
| 4 |
t.string :email, null: false |
|
| 5 |
t.string :provider, null: false |
|
| 6 |
t.text :access_token |
|
| 7 |
t.text :refresh_token, null: false |
|
| 8 |
t.datetime :expires_at, null: false |
|
| 9 |
t.boolean :is_valid, default: true, null: false |
|
| 10 |
t.text :last_error |
|
| 11 | ||
| 12 |
t.timestamps |
|
| 13 |
end |
|
| 14 | ||
| 15 |
add_index :email_oauth_tokens, :email, unique: true |
|
| 16 |
end |
|
| 17 |
end |
|
| lib/redmine/email_oauth/refresher.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require 'net/http' |
|
| 21 |
require 'uri' |
|
| 22 |
require 'json' |
|
| 23 | ||
| 24 |
module Redmine |
|
| 25 |
module EmailOAuth |
|
| 26 |
class Refresher |
|
| 27 |
class << self |
|
| 28 |
def refresh!(token, force: false) |
|
| 29 |
return true if !force && !token.refresh_needed? |
|
| 30 | ||
| 31 |
config = Redmine::Configuration['email_oauth'] |
|
| 32 |
provider_config = config&.[]('providers')&.[](token.provider.to_s)
|
|
| 33 | ||
| 34 |
if provider_config.blank? |
|
| 35 |
error_msg = "Provider configuration for '#{token.provider}' is missing in configuration.yml."
|
|
| 36 |
handle_failure(token, error_msg) |
|
| 37 |
return false |
|
| 38 |
end |
|
| 39 | ||
| 40 |
token_uri = token_endpoint(token.provider, provider_config) |
|
| 41 |
payload = refresh_payload(token, provider_config) |
|
| 42 | ||
| 43 |
begin |
|
| 44 |
res = Net::HTTP.post_form(token_uri, payload) |
|
| 45 |
if res.is_a?(Net::HTTPSuccess) |
|
| 46 |
data = JSON.parse(res.body) |
|
| 47 |
|
|
| 48 |
expires_in = (data['expires_in'] || 3600).to_i.seconds |
|
| 49 |
token.access_token = data['access_token'] |
|
| 50 |
token.refresh_token = data['refresh_token'] if data['refresh_token'].present? |
|
| 51 |
token.expires_at = Time.now + expires_in |
|
| 52 |
token.is_valid = true |
|
| 53 |
token.last_error = nil |
|
| 54 | ||
| 55 |
if token.save |
|
| 56 |
true |
|
| 57 |
else |
|
| 58 |
error_msg = "Failed to save refreshed token: #{token.errors.full_messages.join(', ')}"
|
|
| 59 |
handle_failure(token, error_msg) |
|
| 60 |
false |
|
| 61 |
end |
|
| 62 |
else |
|
| 63 |
error_msg = "OAuth provider returned error [#{res.code}]: #{res.body}"
|
|
| 64 |
handle_failure(token, error_msg) |
|
| 65 |
false |
|
| 66 |
end |
|
| 67 |
rescue => e |
|
| 68 |
error_msg = "Exception during token refresh: #{e.message}"
|
|
| 69 |
handle_failure(token, error_msg) |
|
| 70 |
false |
|
| 71 |
end |
|
| 72 |
end |
|
| 73 | ||
| 74 |
private |
|
| 75 | ||
| 76 |
def token_endpoint(provider, config) |
|
| 77 |
case provider.to_s |
|
| 78 |
when 'google' |
|
| 79 |
URI("https://oauth2.googleapis.com/token")
|
|
| 80 |
when 'microsoft' |
|
| 81 |
tenant = config['tenant'] || 'common' |
|
| 82 |
URI("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token")
|
|
| 83 |
when 'custom' |
|
| 84 |
URI(config['token_url']) |
|
| 85 |
else |
|
| 86 |
raise "Unsupported provider: #{provider}"
|
|
| 87 |
end |
|
| 88 |
end |
|
| 89 | ||
| 90 |
def refresh_payload(token, config) |
|
| 91 |
payload = {
|
|
| 92 |
client_id: config['client_id'], |
|
| 93 |
client_secret: config['client_secret'], |
|
| 94 |
refresh_token: token.refresh_token, |
|
| 95 |
grant_type: 'refresh_token' |
|
| 96 |
} |
|
| 97 | ||
| 98 |
if token.provider == 'microsoft' |
|
| 99 |
payload[:scope] = 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access' |
|
| 100 |
elsif token.provider == 'custom' && config['scope'].present? |
|
| 101 |
payload[:scope] = config['scope'] |
|
| 102 |
end |
|
| 103 | ||
| 104 |
payload |
|
| 105 |
end |
|
| 106 | ||
| 107 |
def handle_failure(token, error_message) |
|
| 108 |
token.is_valid = false |
|
| 109 |
token.last_error = error_message |
|
| 110 |
token.save(validate: false) # Skip validation if we are just marking as invalid |
|
| 111 | ||
| 112 |
# Send email alert to system administrators |
|
| 113 |
begin |
|
| 114 |
Mailer.deliver_email_oauth_refresh_failure(token, error_message) |
|
| 115 |
rescue => e |
|
| 116 |
Rails.logger.error "Failed to send OAuth refresh failure email: #{e.message}"
|
|
| 117 |
end |
|
| 118 |
end |
|
| 119 |
end |
|
| 120 |
end |
|
| 121 |
end |
|
| 122 |
end |
|
| lib/redmine/imap.rb | ||
|---|---|---|
| 18 | 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 19 | 19 | |
| 20 | 20 |
require 'net/imap' |
| 21 |
require_relative 'email_oauth/refresher' |
|
| 21 | 22 | |
| 22 | 23 |
module Redmine |
| 23 | 24 |
module IMAP |
| ... | ... | |
| 41 | 42 |
if starttls |
| 42 | 43 |
imap.starttls |
| 43 | 44 |
end |
| 44 |
imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil? |
|
| 45 |
if imap_options[:username].present? |
|
| 46 |
token_record = EmailOauthToken.find_by(email: imap_options[:username]) |
|
| 47 |
if token_record |
|
| 48 |
# Refresh token if needed |
|
| 49 |
if Redmine::EmailOAuth::Refresher.refresh!(token_record) |
|
| 50 |
imap.authenticate('XOAUTH2', imap_options[:username], token_record.access_token)
|
|
| 51 |
else |
|
| 52 |
raise "OAuth 2.0 authentication failed for #{imap_options[:username]}: #{token_record.last_error}"
|
|
| 53 |
end |
|
| 54 |
else |
|
| 55 |
imap.login(imap_options[:username], imap_options[:password]) |
|
| 56 |
end |
|
| 57 |
end |
|
| 45 | 58 |
imap.select(folder) |
| 46 | 59 |
imap.uid_search(['NOT', 'SEEN']).each do |uid| |
| 47 | 60 |
msg = imap.uid_fetch(uid, 'RFC822')[0].attr['RFC822'] |
| test/functional/admin_controller_test.rb | ||
|---|---|---|
| 31 | 31 |
assert_select 'div#admin-menu a.icon-webhook[href="/admin/webhooks"]' |
| 32 | 32 |
end |
| 33 | 33 | |
| 34 |
def test_index_shows_oauth_warning_banner_when_token_is_invalid |
|
| 35 |
EmailOauthToken.delete_all |
|
| 36 |
EmailOauthToken.create!( |
|
| 37 |
email: 'bad-token@example.net', |
|
| 38 |
provider: 'microsoft', |
|
| 39 |
refresh_token: 'refresh', |
|
| 40 |
expires_at: 10.minutes.ago, |
|
| 41 |
is_valid: false |
|
| 42 |
) |
|
| 43 |
get :index |
|
| 44 |
assert_select 'div.flash.error', :text => /One or more configured OAuth 2.0 incoming email accounts/ |
|
| 45 |
end |
|
| 46 | ||
| 34 | 47 |
def test_index_with_no_configuration_data |
| 35 | 48 |
delete_configuration_data |
| 36 | 49 |
get :index |
| test/functional/email_oauth_controller_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../test_helper' |
|
| 21 | ||
| 22 |
class EmailOauthControllerTest < Redmine::ControllerTest |
|
| 23 |
def setup |
|
| 24 |
User.current = nil |
|
| 25 |
@request.session[:user_id] = 1 # Admin User |
|
| 26 |
EmailOauthToken.delete_all |
|
| 27 |
|
|
| 28 |
# Configure mock OAuth settings |
|
| 29 |
@mock_config = {
|
|
| 30 |
'providers' => {
|
|
| 31 |
'google' => {
|
|
| 32 |
'client_id' => 'google_id', |
|
| 33 |
'client_secret' => 'google_secret' |
|
| 34 |
}, |
|
| 35 |
'microsoft' => {
|
|
| 36 |
'client_id' => 'ms_id', |
|
| 37 |
'client_secret' => 'ms_secret', |
|
| 38 |
'tenant' => 'common' |
|
| 39 |
} |
|
| 40 |
} |
|
| 41 |
} |
|
| 42 |
end |
|
| 43 | ||
| 44 |
def test_index_should_require_admin |
|
| 45 |
@request.session[:user_id] = nil # Anonymous |
|
| 46 |
get :index |
|
| 47 |
assert_response :redirect |
|
| 48 |
assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fadmin%2Fmail_handler%2Foauth' |
|
| 49 | ||
| 50 |
@request.session[:user_id] = 2 # Non-admin user |
|
| 51 |
get :index |
|
| 52 |
assert_response :forbidden |
|
| 53 |
end |
|
| 54 | ||
| 55 |
def test_index |
|
| 56 |
EmailOauthToken.create!( |
|
| 57 |
email: 'redmine@example.net', |
|
| 58 |
provider: 'google', |
|
| 59 |
refresh_token: 'refresh', |
|
| 60 |
expires_at: 1.hour.from_now |
|
| 61 |
) |
|
| 62 | ||
| 63 |
get :index |
|
| 64 |
assert_response :success |
|
| 65 |
assert_select 'tr td.name strong', text: 'redmine@example.net' |
|
| 66 |
end |
|
| 67 | ||
| 68 |
def test_new |
|
| 69 |
Redmine::Configuration.with 'email_oauth' => @mock_config do |
|
| 70 |
get :new |
|
| 71 |
assert_response :success |
|
| 72 |
assert_select 'select[name=?]', 'email_oauth_token[provider]' do |
|
| 73 |
assert_select 'option[value=google]', text: 'Google' |
|
| 74 |
assert_select 'option[value=microsoft]', text: 'Microsoft' |
|
| 75 |
end |
|
| 76 |
end |
|
| 77 |
end |
|
| 78 | ||
| 79 |
def test_initiate_redirects_to_google |
|
| 80 |
Redmine::Configuration.with 'email_oauth' => @mock_config do |
|
| 81 |
post :initiate, params: {
|
|
| 82 |
email_oauth_token: {
|
|
| 83 |
email: 'test@example.net', |
|
| 84 |
provider: 'google' |
|
| 85 |
} |
|
| 86 |
} |
|
| 87 |
|
|
| 88 |
# Verify redirection to Google's authorize URL |
|
| 89 |
assert_response :redirect |
|
| 90 |
assert_match %r{\Ahttps://accounts\.google\.com/o/oauth2/v2/auth}, @response.redirect_url
|
|
| 91 |
assert_match %r{client_id=google_id}, @response.redirect_url
|
|
| 92 |
assert_match %r{scope=https%3A%2F%2Fmail\.google\.com%2F}, @response.redirect_url
|
|
| 93 |
assert_match %r{login_hint=test%40example\.net}, @response.redirect_url
|
|
| 94 |
|
|
| 95 |
# Verify session is populated for callback verification |
|
| 96 |
assert_equal 'test@example.net', session[:email_oauth_email] |
|
| 97 |
assert_equal 'google', session[:email_oauth_provider] |
|
| 98 |
assert_not_nil session[:email_oauth_state] |
|
| 99 |
end |
|
| 100 |
end |
|
| 101 | ||
| 102 |
def test_initiate_redirects_to_microsoft |
|
| 103 |
Redmine::Configuration.with 'email_oauth' => @mock_config do |
|
| 104 |
post :initiate, params: {
|
|
| 105 |
email_oauth_token: {
|
|
| 106 |
email: 'ms@example.net', |
|
| 107 |
provider: 'microsoft' |
|
| 108 |
} |
|
| 109 |
} |
|
| 110 | ||
| 111 |
# Verify redirection to MS Azure authorize URL |
|
| 112 |
assert_response :redirect |
|
| 113 |
assert_match %r{\Ahttps://login\.microsoftonline\.com/common/oauth2/v2\.0/authorize}, @response.redirect_url
|
|
| 114 |
assert_match %r{client_id=ms_id}, @response.redirect_url
|
|
| 115 |
assert_match %r{scope=https%3A%2F%2Foutlook\.office\.com%2FIMAP\.AccessAsUser\.All\+offline_access}, @response.redirect_url
|
|
| 116 |
end |
|
| 117 |
end |
|
| 118 | ||
| 119 |
def test_callback_success |
|
| 120 |
Redmine::Configuration.with 'email_oauth' => @mock_config do |
|
| 121 |
# Set up active authorization session state |
|
| 122 |
state_token = 'securestate' |
|
| 123 |
@request.session[:email_oauth_state] = state_token |
|
| 124 |
@request.session[:email_oauth_email] = 'success@example.net' |
|
| 125 |
@request.session[:email_oauth_provider] = 'google' |
|
| 126 | ||
| 127 |
# Mock successful token exchange response from Google |
|
| 128 |
mock_response = Net::HTTPSuccess.new('1.1', '200', 'OK')
|
|
| 129 |
mock_response.stubs(:body).returns({
|
|
| 130 |
access_token: 'mock_access', |
|
| 131 |
refresh_token: 'mock_refresh', |
|
| 132 |
expires_in: 3600 |
|
| 133 |
}.to_json) |
|
| 134 | ||
| 135 |
Net::HTTP.expects(:post_form).with( |
|
| 136 |
URI("https://oauth2.googleapis.com/token"),
|
|
| 137 |
{
|
|
| 138 |
client_id: 'google_id', |
|
| 139 |
client_secret: 'google_secret', |
|
| 140 |
code: 'auth_code_123', |
|
| 141 |
redirect_uri: 'http://test.host/admin/mail_handler/oauth/callback', |
|
| 142 |
grant_type: 'authorization_code' |
|
| 143 |
} |
|
| 144 |
).returns(mock_response) |
|
| 145 | ||
| 146 |
get :callback, params: {
|
|
| 147 |
state: state_token, |
|
| 148 |
code: 'auth_code_123' |
|
| 149 |
} |
|
| 150 | ||
| 151 |
# Should redirect to index with success message |
|
| 152 |
assert_response :redirect |
|
| 153 |
assert_redirected_to email_oauth_index_path |
|
| 154 |
assert_equal I18n.t(:notice_successful_create), flash[:notice] |
|
| 155 | ||
| 156 |
# Verify record was created/updated in the database |
|
| 157 |
token = EmailOauthToken.find_by(email: 'success@example.net') |
|
| 158 |
assert_not_nil token |
|
| 159 |
assert_equal 'google', token.provider |
|
| 160 |
assert_equal 'mock_access', token.access_token |
|
| 161 |
assert_equal 'mock_refresh', token.refresh_token |
|
| 162 |
assert token.expires_at > Time.now |
|
| 163 |
assert token.is_valid? |
|
| 164 |
end |
|
| 165 |
end |
|
| 166 | ||
| 167 |
def test_destroy |
|
| 168 |
token = EmailOauthToken.create!( |
|
| 169 |
email: 'delete_me@example.net', |
|
| 170 |
provider: 'google', |
|
| 171 |
refresh_token: 'refresh', |
|
| 172 |
expires_at: 1.hour.from_now |
|
| 173 |
) |
|
| 174 | ||
| 175 |
assert_difference 'EmailOauthToken.count', -1 do |
|
| 176 |
delete :destroy, params: { id: token.id }
|
|
| 177 |
end |
|
| 178 | ||
| 179 |
assert_redirected_to email_oauth_index_path |
|
| 180 |
assert_equal I18n.t(:notice_successful_delete), flash[:notice] |
|
| 181 |
end |
|
| 182 |
end |
|
| test/functional/settings_controller_test.rb | ||
|---|---|---|
| 327 | 327 |
assert_equal '1', Setting.mail_handler_enable_regex_delimiters |
| 328 | 328 |
assert_equal 'On .*, .* at .*, .* <.*<mailto:.*>> wrote:', Setting.mail_handler_body_delimiters |
| 329 | 329 |
end |
| 330 | ||
| 331 |
def test_get_index_with_mail_handler_tab_renders_oauth_providers |
|
| 332 |
@request.session[:user_id] = 1 # Admin |
|
| 333 |
mock_config = {
|
|
| 334 |
'providers' => {
|
|
| 335 |
'google' => { 'client_id' => 'g', 'client_secret' => 's' },
|
|
| 336 |
'microsoft' => { 'client_id' => 'm', 'client_secret' => 's' }
|
|
| 337 |
} |
|
| 338 |
} |
|
| 339 | ||
| 340 |
Redmine::Configuration.with 'email_oauth' => mock_config do |
|
| 341 |
get :index, :params => { :tab => 'mail_handler' }
|
|
| 342 |
assert_response :success |
|
| 343 |
assert_select 'legend', :text => 'Enabled OAuth providers' |
|
| 344 |
assert_select 'fieldset.settings ul li span', :text => 'Google' |
|
| 345 |
assert_select 'fieldset.settings ul li span', :text => 'Microsoft' |
|
| 346 |
end |
|
| 347 |
end |
|
| 348 | ||
| 349 |
def test_get_index_with_mail_handler_tab_renders_no_oauth_providers_message |
|
| 350 |
@request.session[:user_id] = 1 # Admin |
|
| 351 |
Redmine::Configuration.with 'email_oauth' => nil do |
|
| 352 |
get :index, :params => { :tab => 'mail_handler' }
|
|
| 353 |
assert_response :success |
|
| 354 |
assert_select 'div.nodata', :text => /Email OAuth providers are not configured/ |
|
| 355 |
end |
|
| 356 |
end |
|
| 330 | 357 |
end |
| test/unit/email_oauth_refresher_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../test_helper' |
|
| 21 |
require 'redmine/email_oauth/refresher' |
|
| 22 | ||
| 23 |
class EmailOauthRefresherTest < ActiveSupport::TestCase |
|
| 24 |
fixtures :users |
|
| 25 | ||
| 26 |
def setup |
|
| 27 |
EmailOauthToken.delete_all |
|
| 28 |
@token = EmailOauthToken.create!( |
|
| 29 |
email: 'oauth-test@example.net', |
|
| 30 |
provider: 'google', |
|
| 31 |
access_token: 'initial_access_token', |
|
| 32 |
refresh_token: 'initial_refresh_token', |
|
| 33 |
expires_at: 10.minutes.ago, |
|
| 34 |
is_valid: true |
|
| 35 |
) |
|
| 36 |
# Clear any deliveries |
|
| 37 |
ActionMailer::Base.deliveries.clear |
|
| 38 |
end |
|
| 39 | ||
| 40 |
def test_refresh_not_needed |
|
| 41 |
@token.update!(expires_at: 1.hour.from_now) |
|
| 42 |
# No Net::HTTP requests should be made |
|
| 43 |
Net::HTTP.expects(:post_form).never |
|
| 44 |
assert Redmine::EmailOAuth::Refresher.refresh!(@token) |
|
| 45 |
assert @token.is_valid? |
|
| 46 |
assert_nil @token.last_error |
|
| 47 |
end |
|
| 48 | ||
| 49 |
def test_refresh_with_missing_config |
|
| 50 |
Redmine::Configuration.with 'email_oauth' => nil do |
|
| 51 |
assert_not Redmine::EmailOAuth::Refresher.refresh!(@token) |
|
| 52 |
@token.reload |
|
| 53 |
assert_not @token.is_valid? |
|
| 54 |
assert_match /missing in configuration.yml/, @token.last_error |
|
| 55 |
assert_equal 1, ActionMailer::Base.deliveries.size |
|
| 56 |
end |
|
| 57 |
end |
|
| 58 | ||
| 59 |
def test_refresh_success |
|
| 60 |
config = {
|
|
| 61 |
'providers' => {
|
|
| 62 |
'google' => {
|
|
| 63 |
'client_id' => 'google_id', |
|
| 64 |
'client_secret' => 'google_secret' |
|
| 65 |
} |
|
| 66 |
} |
|
| 67 |
} |
|
| 68 | ||
| 69 |
mock_response = mock('response')
|
|
| 70 |
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) |
|
| 71 |
mock_response.stubs(:body).returns({
|
|
| 72 |
'access_token' => 'new_access_token', |
|
| 73 |
'expires_in' => 3600 |
|
| 74 |
}.to_json) |
|
| 75 | ||
| 76 |
Redmine::Configuration.with 'email_oauth' => config do |
|
| 77 |
Net::HTTP.expects(:post_form).with( |
|
| 78 |
URI("https://oauth2.googleapis.com/token"),
|
|
| 79 |
{
|
|
| 80 |
client_id: 'google_id', |
|
| 81 |
client_secret: 'google_secret', |
|
| 82 |
refresh_token: 'initial_refresh_token', |
|
| 83 |
grant_type: 'refresh_token' |
|
| 84 |
} |
|
| 85 |
).returns(mock_response) |
|
| 86 | ||
| 87 |
assert Redmine::EmailOAuth::Refresher.refresh!(@token) |
|
| 88 |
@token.reload |
|
| 89 |
assert_equal 'new_access_token', @token.access_token |
|
| 90 |
assert @token.is_valid? |
|
| 91 |
assert_nil @token.last_error |
|
| 92 |
assert_equal 0, ActionMailer::Base.deliveries.size |
|
| 93 |
end |
|
| 94 |
end |
|
| 95 | ||
| 96 |
def test_refresh_custom_provider_success |
|
| 97 |
@token.update!(provider: 'custom', refresh_token: 'custom_refresh') |
|
| 98 |
config = {
|
|
| 99 |
'providers' => {
|
|
| 100 |
'custom' => {
|
|
| 101 |
'client_id' => 'custom_id', |
|
| 102 |
'client_secret' => 'custom_secret', |
|
| 103 |
'token_url' => 'https://oauth.custom.local/token', |
|
| 104 |
'scope' => 'custom_scope' |
|
| 105 |
} |
|
| 106 |
} |
|
| 107 |
} |
|
| 108 | ||
| 109 |
mock_response = mock('response')
|
|
| 110 |
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) |
|
| 111 |
mock_response.stubs(:body).returns({
|
|
| 112 |
'access_token' => 'new_custom_access_token', |
|
| 113 |
'expires_in' => 1800 |
|
| 114 |
}.to_json) |
|
| 115 | ||
| 116 |
Redmine::Configuration.with 'email_oauth' => config do |
|
| 117 |
Net::HTTP.expects(:post_form).with( |
|
| 118 |
URI("https://oauth.custom.local/token"),
|
|
| 119 |
{
|
|
| 120 |
client_id: 'custom_id', |
|
| 121 |
client_secret: 'custom_secret', |
|
| 122 |
refresh_token: 'custom_refresh', |
|
| 123 |
grant_type: 'refresh_token', |
|
| 124 |
scope: 'custom_scope' |
|
| 125 |
} |
|
| 126 |
).returns(mock_response) |
|
| 127 | ||
| 128 |
assert Redmine::EmailOAuth::Refresher.refresh!(@token) |
|
| 129 |
@token.reload |
|
| 130 |
assert_equal 'new_custom_access_token', @token.access_token |
|
| 131 |
assert @token.is_valid? |
|
| 132 |
assert_nil @token.last_error |
|
| 133 |
end |
|
| 134 |
end |
|
| 135 | ||
| 136 |
def test_refresh_failure |
|
| 137 |
config = {
|
|
| 138 |
'providers' => {
|
|
| 139 |
'google' => {
|
|
| 140 |
'client_id' => 'google_id', |
|
| 141 |
'client_secret' => 'google_secret' |
|
| 142 |
} |
|
| 143 |
} |
|
| 144 |
} |
|
| 145 | ||
| 146 |
mock_response = mock('response')
|
|
| 147 |
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false) |
|
| 148 |
mock_response.stubs(:code).returns('400')
|
|
| 149 |
mock_response.stubs(:body).returns('{"error":"invalid_grant"}')
|
|
| 150 | ||
| 151 |
Redmine::Configuration.with 'email_oauth' => config do |
|
| 152 |
Net::HTTP.expects(:post_form).returns(mock_response) |
|
| 153 | ||
| 154 |
assert_not Redmine::EmailOAuth::Refresher.refresh!(@token) |
|
| 155 |
@token.reload |
|
| 156 |
assert_not @token.is_valid? |
|
| 157 |
assert_match /invalid_grant/, @token.last_error |
|
| 158 |
assert_equal 1, ActionMailer::Base.deliveries.size |
|
| 159 | ||
| 160 |
# Check email delivery content |
|
| 161 |
mail = ActionMailer::Base.deliveries.first |
|
| 162 |
assert_match /OAuth 2.0 Email Account Authentication Failure/, mail.subject |
|
| 163 |
assert_match /oauth-test@example.net/, mail.body.encoded |
|
| 164 |
end |
|
| 165 |
end |
|
| 166 | ||
| 167 |
def test_imap_integration_with_oauth |
|
| 168 |
@token.update!(expires_at: 1.hour.from_now) |
|
| 169 | ||
| 170 |
mock_imap = mock('imap')
|
|
| 171 |
Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap)
|
|
| 172 |
mock_imap.expects(:authenticate).with('XOAUTH2', 'oauth-test@example.net', 'initial_access_token')
|
|
| 173 |
mock_imap.expects(:select).with('INBOX')
|
|
| 174 |
mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([]) |
|
| 175 |
mock_imap.expects(:expunge) |
|
| 176 |
mock_imap.expects(:logout) |
|
| 177 |
mock_imap.expects(:disconnect) |
|
| 178 | ||
| 179 |
Redmine::IMAP.check({
|
|
| 180 |
host: '127.0.0.1', |
|
| 181 |
port: '143', |
|
| 182 |
username: 'oauth-test@example.net' |
|
| 183 |
}) |
|
| 184 |
end |
|
| 185 | ||
| 186 |
def test_imap_integration_fallback_to_login |
|
| 187 |
mock_imap = mock('imap')
|
|
| 188 |
Net::IMAP.expects(:new).with('127.0.0.1', port: '143', ssl: false).returns(mock_imap)
|
|
| 189 |
mock_imap.expects(:login).with('plain-user@example.net', 'password')
|
|
| 190 |
mock_imap.expects(:select).with('INBOX')
|
|
| 191 |
mock_imap.expects(:uid_search).with(['NOT', 'SEEN']).returns([]) |
|
| 192 |
mock_imap.expects(:expunge) |
|
| 193 |
mock_imap.expects(:logout) |
|
| 194 |
mock_imap.expects(:disconnect) |
|
| 195 | ||
| 196 |
Redmine::IMAP.check({
|
|
| 197 |
host: '127.0.0.1', |
|
| 198 |
port: '143', |
|
| 199 |
username: 'plain-user@example.net', |
|
| 200 |
password: 'password' |
|
| 201 |
}) |
|
| 202 |
end |
|
| 203 |
end |
|
| test/unit/email_oauth_token_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../test_helper' |
|
| 21 | ||
| 22 |
class EmailOauthTokenTest < ActiveSupport::TestCase |
|
| 23 |
def setup |
|
| 24 |
EmailOauthToken.delete_all |
|
| 25 |
end |
|
| 26 | ||
| 27 |
def test_validations |
|
| 28 |
# Valid record |
|
| 29 |
token = EmailOauthToken.new( |
|
| 30 |
email: 'redmine@example.net', |
|
| 31 |
provider: 'microsoft', |
|
| 32 |
refresh_token: 'valid_refresh_token', |
|
| 33 |
expires_at: 1.hour.from_now |
|
| 34 |
) |
|
| 35 |
assert token.valid? |
|
| 36 | ||
| 37 |
# Missing email |
|
| 38 |
token.email = nil |
|
| 39 |
assert_not token.valid? |
|
| 40 |
assert_includes token.errors[:email], "cannot be blank" |
|
| 41 | ||
| 42 |
# Invalid email format |
|
| 43 |
token.email = 'not-an-email' |
|
| 44 |
assert_not token.valid? |
|
| 45 | ||
| 46 |
# Missing provider |
|
| 47 |
token.email = 'redmine@example.net' |
|
| 48 |
token.provider = nil |
|
| 49 |
assert_not token.valid? |
|
| 50 | ||
| 51 |
# Invalid provider |
|
| 52 |
token.provider = 'invalid_provider' |
|
| 53 |
assert_not token.valid? |
|
| 54 | ||
| 55 |
# Duplicate email (case-insensitive) |
|
| 56 |
EmailOauthToken.create!( |
|
| 57 |
email: 'redmine@example.net', |
|
| 58 |
provider: 'microsoft', |
|
| 59 |
refresh_token: 'token1', |
|
| 60 |
expires_at: 1.hour.from_now |
|
| 61 |
) |
|
| 62 |
duplicate = EmailOauthToken.new( |
|
| 63 |
email: 'REDMINE@example.net', |
|
| 64 |
provider: 'google', |
|
| 65 |
refresh_token: 'token2', |
|
| 66 |
expires_at: 1.hour.from_now |
|
| 67 |
) |
|
| 68 |
assert_not duplicate.valid? |
|
| 69 |
assert_includes duplicate.errors[:email], "has already been taken" |
|
| 70 |
end |
|
| 71 | ||
| 72 |
def test_ciphering |
|
| 73 |
Redmine::Configuration.with 'database_cipher_key' => 'secret' do |
|
| 74 |
token = EmailOauthToken.create!( |
|
| 75 |
email: 'redmine@example.net', |
|
| 76 |
provider: 'google', |
|
| 77 |
access_token: 'secret_access', |
|
| 78 |
refresh_token: 'secret_refresh', |
|
| 79 |
expires_at: 1.hour.from_now |
|
| 80 |
) |
|
| 81 | ||
| 82 |
# In-memory reading should be transparently decrypted |
|
| 83 |
assert_equal 'secret_access', token.access_token |
|
| 84 |
assert_equal 'secret_refresh', token.refresh_token |
|
| 85 | ||
| 86 |
# Reading raw database attributes should show encrypted values (with 'aes-256-cbc:') |
|
| 87 |
raw_record = ActiveRecord::Base.connection.select_one( |
|
| 88 |
"SELECT access_token, refresh_token FROM email_oauth_tokens WHERE id = #{token.id}"
|
|
| 89 |
) |
|
| 90 |
assert_match /\Aaes-256-cbc:/, raw_record['access_token'] |
|
| 91 |
assert_match /\Aaes-256-cbc:/, raw_record['refresh_token'] |
|
| 92 |
end |
|
| 93 |
end |
|
| 94 | ||
| 95 |
def test_expired_and_refresh_needed |
|
| 96 |
token = EmailOauthToken.new( |
|
| 97 |
email: 'redmine@example.net', |
|
| 98 |
provider: 'microsoft', |
|
| 99 |
refresh_token: 'refresh', |
|
| 100 |
expires_at: 10.minutes.ago |
|
| 101 |
) |
|
| 102 |
assert token.expired? |
|
| 103 |
assert token.refresh_needed? |
|
| 104 | ||
| 105 |
token.expires_at = 2.minutes.from_now |
|
| 106 |
assert_not token.expired? |
|
| 107 |
assert token.refresh_needed? # within 5 minutes grace period |
|
| 108 | ||
| 109 |
token.expires_at = 10.minutes.from_now |
|
| 110 |
assert_not token.expired? |
|
| 111 |
assert_not token.refresh_needed? |
|
| 112 |
end |
|
| 113 |
end |
|