Project

General

Profile

Feature #43023 » Feature__move_to_modern_authentication(OAuth_2_0)_from_IMAP.patch

Jan Catrysse, 2025-07-16 22:48

View differences:

.gitignore (revision 87972acede20e953e34edc18e246d583af860f8f) → .gitignore (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
50 50

  
51 51
/config/master.key
52 52
/config/credentials.yml.enc
53
/config/email_oauth*.yml
Gemfile (revision 87972acede20e953e34edc18e246d583af860f8f) → Gemfile (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
21 21
gem 'net-imap', '~> 0.3.9'
22 22
gem 'net-pop', '~> 0.1.2'
23 23
gem 'net-smtp', '~> 0.3.3'
24
gem 'oauth2', '~> 2.0'
25
gem 'gmail_xoauth', '~> 0.4.3'
24 26
gem 'rexml', require: false if Gem.ruby_version >= Gem::Version.new('3.0')
25 27

  
26 28
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
lib/redmine/imap.rb (revision 87972acede20e953e34edc18e246d583af860f8f) → lib/redmine/imap.rb (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
16 16
# You should have received a copy of the GNU General Public License
17 17
# along with this program; if not, write to the Free Software
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20 19
require 'net/imap'
21 20

  
22 21
module Redmine
......
28 27
        ssl = !imap_options[:ssl].nil?
29 28
        starttls = !imap_options[:starttls].nil?
30 29
        folder = imap_options[:folder] || 'INBOX'
30
        auth_type = imap_options[:auth_type] || 'LOGIN'
31 31

  
32 32
        imap = Net::IMAP.new(host, port, ssl)
33 33
        if starttls
34 34
          imap.starttls
35 35
        end
36
        imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
36
        if auth_type == "XOAUTH2" 
37
          require 'gmail_xoauth' unless defined?(Net::IMAP::XOauth2Authenticator) && Net::IMAP::XOauth2Authenticator.class == Class
38
        end
39
        if auth_type == "LOGIN"
40
          imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
41
        else
42
          imap.authenticate(auth_type, imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
43
        end
37 44
        imap.select(folder)
38 45
        imap.uid_search(['NOT', 'SEEN']).each do |uid|
39 46
          msg = imap.uid_fetch(uid,'RFC822')[0].attr['RFC822']
/dev/null (revision e69e0e367e2936c62f525f1d3cc20628f76050b5) → lib/tasks/email_oauth.rake (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
1
# Redmine - project management software
2
# Copyright (C) 2006-2022  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
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
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

  
18
require 'net/imap'
19
require 'oauth2'
20
require 'uri'
21
require 'cgi'
22

  
23
require 'gmail_xoauth' unless defined?(Net::IMAP::XOauth2Authenticator) && Net::IMAP::XOauth2Authenticator.class == Class
24

  
25
namespace :redmine do
26
  namespace :email do
27

  
28
    # token_file = path and basename /app/redmine/config/email_token (email_token.yml and email_token_client.yml are created)
29
    # client = Azure app Client ID
30
    # secret = Azure app Secret key
31
    # tenant = Azure app Tenant ID
32

  
33
    desc "Init Office 365 authorization"
34
    task :o365_oauth2_init => :environment do
35

  
36
      token_file = ENV['token_file']
37
      client_id = ENV['client']
38
      client_secret = ENV['secret']
39
      tenant_id = ENV['tenant']
40

  
41
      scope = [
42
        "offline_access",
43
        "https://outlook.office.com/User.Read",
44
        "https://outlook.office.com/IMAP.AccessAsUser.All",
45
        "https://outlook.office.com/POP.AccessAsUser.All",
46
        "https://outlook.office.com/SMTP.Send",
47
      ]
48

  
49
      client_config = {
50
        "tenant_id" => tenant_id,
51
        "client_id" => client_id,
52
        "client_secret" => client_secret,
53
        "site" => 'https://login.microsoftonline.com',
54
        "authorize_url" => "/#{tenant_id}/oauth2/v2.0/authorize",
55
        "token_url" => "/#{tenant_id}/oauth2/v2.0/token"
56
      }
57

  
58
      client = OAuth2::Client.new(client_config['client_id'], client_config['client_secret'],
59
                                  site: client_config['site'], authorize_url: client_config['authorize_url'], token_url: client_config['token_url'])
60

  
61
      print("Go to URL: #{client.auth_code.authorize_url(scope: scope.join(" "))}\n")
62
      print("Enter full URL after authorize:")
63
      access_token = client.auth_code.get_token(CGI.parse(URI.parse(STDIN.gets.strip).query)["code"].first, client_id: client_id)
64

  
65
      File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
66
      File.write("#{token_file}_client.yml", client_config.to_yaml)
67

  
68
      puts "AUTH OK!"
69
    end
70

  
71
    desc "Read emails from an IMAP server authorized via OAuth2"
72
    task :receive_imap_oauth2 => :environment do
73

  
74
      token_file = ENV['token_file']
75

  
76
      unless token_file && File.exist?("#{token_file}.yml") && File.exist?("#{token_file}_client.yml")
77
        raise "token_file not defined or not exists"
78
      end
79

  
80
      client_config = YAML.load_file("#{token_file}_client.yml")
81
      client = OAuth2::Client.new(client_config['client_id'], client_config['client_secret'],
82
                                  site: client_config['site'], authorize_url: client_config['authorize_url'], token_url: client_config['token_url'])
83

  
84
      access_token = OAuth2::AccessToken.from_hash(client, YAML.unsafe_load_file("#{token_file}.yml"))
85

  
86
      if access_token.expired?
87
        access_token = access_token.refresh!
88
        File.write("#{token_file}.yml", access_token.to_hash.to_yaml)
89
      end
90

  
91
      imap_options = {:host => ENV['host'],
92
                      :port => ENV['port'],
93
                      :ssl => ENV['ssl'],
94
                      :starttls => ENV['starttls'],
95
                      :username => ENV['username'],
96
                      :password => access_token.token,
97
                      :auth_type => 'XOAUTH2',
98
                      :folder => ENV['folder'],
99
                      :move_on_success => ENV['move_on_success'],
100
                      :move_on_failure => ENV['move_on_failure']}
101

  
102
      Mailer.with_synched_deliveries do
103
        Redmine::IMAP.check(imap_options, MailHandler.extract_options_from_env(ENV))
104
      end
105
    end
106

  
107
  end
108

  
109
end
/dev/null (revision e69e0e367e2936c62f525f1d3cc20628f76050b5) → test/unit/lib/redmine/imap_test.rb (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
1
# frozen_string_literal: true
2

  
3
require_relative '../../../test_helper'
4
require 'net/imap'
5

  
6
class Redmine::IMAPTest < ActiveSupport::TestCase
7
  def test_check_uses_login_by_default
8
    imap = mock('imap')
9
    Net::IMAP.expects(:new).with('127.0.0.1', '143', false).returns(imap)
10
    imap.expects(:starttls).never
11
    imap.expects(:login).with('user', 'secret')
12
    imap.expects(:select).with('INBOX')
13
    imap.expects(:uid_search).returns([])
14
    imap.expects(:expunge)
15
    imap.expects(:logout)
16
    imap.expects(:disconnect)
17

  
18
    Redmine::IMAP.check(:username => 'user', :password => 'secret')
19
  end
20

  
21
  def test_check_uses_authenticate_when_auth_type_is_set
22
    imap = mock('imap')
23
    Net::IMAP.expects(:new).with('imap.example.com', '993', true).returns(imap)
24
    imap.expects(:starttls)
25
    imap.expects(:authenticate).with('XOAUTH2', 'user', 'token')
26
    imap.expects(:select).with('INBOX')
27
    imap.expects(:uid_search).returns([])
28
    imap.expects(:expunge)
29
    imap.expects(:logout)
30
    imap.expects(:disconnect)
31

  
32
    Redmine::IMAP.check(:host => 'imap.example.com', :port => '993', :ssl => true,
33
                        :starttls => true, :username => 'user', :password => 'token',
34
                        :auth_type => 'XOAUTH2')
35
  end
36
end
/dev/null (revision e69e0e367e2936c62f525f1d3cc20628f76050b5) → test/unit/lib/tasks/email_oauth_test.rb (revision e69e0e367e2936c62f525f1d3cc20628f76050b5)
1
require 'minitest/autorun'
2
require 'mocha/minitest'
3
require 'yaml'
4
require 'fileutils'
5
require 'tempfile'
6
require 'oauth2'
7

  
8
class EmailOauthTokenRefreshTest < Minitest::Test
9
  def setup
10
    @dir = Dir.mktmpdir
11
    @token_file = File.join(@dir, 'email_token')
12
    File.write("#{@token_file}_client.yml", {
13
      'client_id' => 'id',
14
      'client_secret' => 'secret',
15
      'site' => 'site',
16
      'authorize_url' => 'auth',
17
      'token_url' => 'token'
18
    }.to_yaml)
19
    File.write("#{@token_file}.yml", {
20
      'access_token' => 'old',
21
      'refresh_token' => 'r',
22
      'expires_at' => Time.now.to_i - 3600
23
    }.to_yaml)
24
  end
25

  
26
  def teardown
27
    FileUtils.rm_rf(@dir)
28
  end
29

  
30
  def run_refresh_logic
31
    client_config = YAML.load_file("#{@token_file}_client.yml")
32
    client = OAuth2::Client.new(client_config['client_id'], client_config['client_secret'],
33
                                site: client_config['site'], authorize_url: client_config['authorize_url'], token_url: client_config['token_url'])
34
    access_token = OAuth2::AccessToken.from_hash(client, YAML.unsafe_load_file("#{@token_file}.yml"))
35
    if access_token.expired?
36
      access_token = access_token.refresh!
37
      File.write("#{@token_file}.yml", access_token.to_hash.to_yaml)
38
    end
39
  end
40

  
41
  def test_expired_token_refreshes_and_writes_file
42
    refreshed = stub('token', to_hash: {
43
      'access_token' => 'new',
44
      'refresh_token' => 'r',
45
      'expires_at' => Time.now.to_i + 3600
46
    })
47
    access_token = stub('token')
48
    access_token.stubs(:expired?).returns(true)
49
    access_token.stubs(:refresh!).returns(refreshed)
50

  
51
    OAuth2::Client.expects(:new).returns(:client)
52
    OAuth2::AccessToken.expects(:from_hash).returns(access_token)
53

  
54
    run_refresh_logic
55

  
56
    data = YAML.safe_load(File.read("#{@token_file}.yml"))
57
    assert_equal 'new', data['access_token']
58
  end
59

  
60
  def test_valid_token_is_not_refreshed
61
    access_token = stub('token')
62
    access_token.stubs(:expired?).returns(false)
63

  
64
    OAuth2::Client.expects(:new).returns(:client)
65
    OAuth2::AccessToken.expects(:from_hash).returns(access_token)
66
    access_token.expects(:refresh!).never
67

  
68
    run_refresh_logic
69

  
70
    data = YAML.safe_load(File.read("#{@token_file}.yml"))
71
    assert_equal 'old', data['access_token']
72
  end
73
end
    (1-1/1)