├── .gitignore
├── app
├── views
│ ├── hooks
│ │ └── redmine_sms_auth
│ │ │ ├── _view_my_account.html.erb
│ │ │ └── _view_users_form.html.erb
│ └── account
│ │ └── sms.html.erb
└── models
│ └── auth_source_sms.rb
├── test
├── test_helper.rb
├── unit
│ ├── sms_auth_test.rb
│ └── user_patch_test.rb
└── functional
│ └── account_controller_patch_test.rb
├── config
├── routes.rb
└── locales
│ ├── en.yml
│ └── ru.yml
├── lib
├── redmine_sms_auth
│ └── hooks.rb
├── user_patch.rb
├── sms_auth.rb
└── account_controller_patch.rb
├── db
└── migrate
│ ├── 002_add_mobile_phone_to_users.rb
│ └── 001_add_sms_auth.rb
├── init.rb
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea
--------------------------------------------------------------------------------
/app/views/hooks/redmine_sms_auth/_view_my_account.html.erb:
--------------------------------------------------------------------------------
1 |
<%= form.text_field :mobile_phone %>
--------------------------------------------------------------------------------
/app/views/hooks/redmine_sms_auth/_view_users_form.html.erb:
--------------------------------------------------------------------------------
1 | <%= form.text_field :mobile_phone %>
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # Load the Redmine helper
2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')
3 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | RedmineApp::Application.routes.draw do
2 | post :sms_confirm, to: 'account#sms_confirm', as: :sms_confirm
3 | get :sms_resend, to: 'account#sms_resend', as: :sms_resend
4 | end
5 |
--------------------------------------------------------------------------------
/lib/redmine_sms_auth/hooks.rb:
--------------------------------------------------------------------------------
1 | module RedmineSmsAuth
2 |
3 | class Hooks < Redmine::Hook::ViewListener
4 | render_on :view_users_form, partial: 'hooks/redmine_sms_auth/view_users_form'
5 | render_on :view_my_account, partial: 'hooks/redmine_sms_auth/view_my_account'
6 | end
7 |
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/002_add_mobile_phone_to_users.rb:
--------------------------------------------------------------------------------
1 | class AddMobilePhoneToUsers < ActiveRecord::Migration
2 | def up
3 | unless column_exists? :users, :mobile_phone
4 | add_column :users, :mobile_phone, :string
5 | end
6 |
7 | end
8 |
9 | def down
10 | unless Redmine::Plugin.installed?(:redmine_2fa)
11 | remove_column :users, :mobile_phone
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | sms_password: 'SMS confirmation password'
3 | resend_sms: 'Resend password'
4 | notice_account_invalid_sms_password: 'Wrong SMS confirmation password'
5 | notice_account_sms_resent_again: 'SMS confirmation password sent again'
6 | notice_account_sms_limit_exceeded_failed_attempts: 'Limit exceeded of failed attempts. New password sent'
7 | field_mobile_phone: 'Mobile phone'
8 |
--------------------------------------------------------------------------------
/config/locales/ru.yml:
--------------------------------------------------------------------------------
1 | ru:
2 | sms_password: 'Код подтверждения'
3 | resend_sms: 'Выслать повторно'
4 | notice_account_invalid_sms_password: 'Неверный код подтверждения'
5 | notice_account_sms_resent_again: 'Код подтверждения выслан повторно'
6 | notice_account_sms_limit_exceeded_failed_attempts: 'Превышен предел неудачных попыток. Сгенерирован и выслан новый пароль'
7 | field_mobile_phone: 'Мобильный телефон'
--------------------------------------------------------------------------------
/app/models/auth_source_sms.rb:
--------------------------------------------------------------------------------
1 | class AuthSourceSms < AuthSource
2 |
3 | def authenticate(login, password)
4 | # Just default redmine password check
5 | user = User.where(login: login).first
6 | if user && User.hash_password("#{user.salt}#{User.hash_password(password)}") == user.hashed_password
7 | user
8 | end
9 | end
10 |
11 | def auth_method_name
12 | 'SMS'
13 | end
14 |
15 | def self.allow_password_changes?
16 | true
17 | end
18 |
19 | end
20 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | Redmine::Plugin.register :redmine_sms_auth do
2 | name 'Redmine SMS Auth plugin'
3 | author 'Southbridge'
4 | description 'Plugin adds secondary (2FA) SMS authentication'
5 | version '0.0.4'
6 | url 'https://github.com/southbridgeio/redmine_sms_auth'
7 | author_url 'https://southbridge.io'
8 | end
9 |
10 | ActionDispatch::Callbacks.to_prepare do
11 | require 'sms_auth'
12 | require_dependency 'redmine_sms_auth/hooks'
13 | require_dependency 'account_controller_patch'
14 | require_dependency 'user_patch'
15 | end
16 |
--------------------------------------------------------------------------------
/test/unit/sms_auth_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../../test_helper', __FILE__)
2 |
3 | class SmsAuthTest < ActiveSupport::TestCase
4 |
5 | def test_generate_sms_password
6 | SmsAuth::Configuration.expects(:password_length).returns(5)
7 | password = SmsAuth.generate_sms_password
8 | assert password.length == 5
9 | end
10 |
11 | def test_send_sms_password
12 | SmsAuth::Configuration.expects(:command).returns('echo %{phone} %{password}')
13 | SmsAuth.expects(:system).with('echo 79999999999 1234')
14 | SmsAuth.send_sms_password('79999999999', '1234')
15 | end
16 |
17 | end
--------------------------------------------------------------------------------
/test/unit/user_patch_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../../test_helper', __FILE__)
2 |
3 | class UserPatchTest < ActiveSupport::TestCase
4 | fixtures :users, :roles
5 |
6 | def setup
7 | auth_source = AuthSourceSms.create(name: 'SMS', onthefly_register: false, tls: false)
8 | @user = User.find(2) # jsmith
9 | @user.update_attribute :auth_source_id, auth_source.id
10 | end
11 |
12 | def test_password_changing
13 | old_password = @user.hashed_password
14 | @user.password_confirmation = @user.password = 'new_password'
15 | @user.save
16 | @user.reload
17 | assert @user.hashed_password != old_password
18 | end
19 | end
--------------------------------------------------------------------------------
/lib/user_patch.rb:
--------------------------------------------------------------------------------
1 | require_dependency 'project'
2 | require_dependency 'user'
3 |
4 | module UserPatch
5 |
6 | def self.included(base)
7 | base.send(:include, InstanceMethods)
8 | base.safe_attributes 'mobile_phone'
9 | base.validates_format_of :mobile_phone, :with => /\A[-+0-9]*\z/, :allow_blank => true
10 | base.class_eval do
11 | alias_method_chain :update_hashed_password, :sms_auth
12 | end
13 | end
14 |
15 | module InstanceMethods
16 |
17 | def update_hashed_password_with_sms_auth
18 | if self.auth_source && self.auth_source.auth_method_name == 'SMS'
19 | salt_password(self.password) if self.password
20 | else
21 | update_hashed_password_without_sms_auth
22 | end
23 | end
24 |
25 | end
26 |
27 | end
28 |
29 | User.send(:include, UserPatch)
30 |
--------------------------------------------------------------------------------
/app/views/account/sms.html.erb:
--------------------------------------------------------------------------------
1 |
22 |
23 | <%= javascript_tag "$('#sms_password').focus();" %>
24 |
--------------------------------------------------------------------------------
/db/migrate/001_add_sms_auth.rb:
--------------------------------------------------------------------------------
1 | class AddSmsAuth < ActiveRecord::Migration
2 | def up
3 | AuthSourceSms.create name: 'SMS', onthefly_register: false, tls: false
4 | end
5 |
6 | def down
7 |
8 | if Redmine::Plugin.installed?(:redmine_2fa)
9 | old_auth_source = AuthSource.where(type: 'AuthSourceSms').first
10 | old_auth_source_id = if old_auth_source
11 | old_auth_source.id
12 | else
13 | (User.all.pluck(:auth_source_id).compact.uniq - AuthSource.pluck(:id)).first
14 | end
15 |
16 | AuthSource.where(type: 'AuthSourceSms').destroy_all
17 |
18 | new_auth_source = Redmine2FA::AuthSourceSms.create name: 'SMS', onthefly_register: false, tls: false
19 |
20 | if old_auth_source_id
21 | User.where(auth_source_id: old_auth_source_id).update_all(auth_source_id: new_auth_source.id)
22 | end
23 | else
24 | AuthSource.where(type: 'AuthSourceSms').destroy_all
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/sms_auth.rb:
--------------------------------------------------------------------------------
1 | module SmsAuth
2 |
3 | module Configuration
4 |
5 | def self.command
6 | configuration = Redmine::Configuration['sms_auth']
7 | configuration && configuration['command'] ? configuration['command'] : 'echo %{phone} %{password}'
8 | end
9 |
10 | def self.password_length
11 | configuration = Redmine::Configuration['sms_auth']
12 | configuration && configuration['password_length'] && configuration['password_length'].to_i > 0 ?
13 | configuration['password_length'].to_i : 4
14 | end
15 |
16 | end
17 |
18 | def self.generate_sms_password
19 | Random.srand
20 | sms_password_degree = 10 ** (SmsAuth::Configuration.password_length - 1)
21 | (rand(9 * sms_password_degree) + sms_password_degree).to_s
22 | end
23 |
24 | def self.send_sms_password(phone, password)
25 | phone = phone.gsub(/[^-+0-9]+/,'') # Additional phone sanitizing
26 | command = SmsAuth::Configuration.command
27 | command = command.sub('%{phone}', phone).sub('%{password}', password)
28 | system command
29 | end
30 |
31 | end
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2013-2017 Igor Olemskoi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/account_controller_patch.rb:
--------------------------------------------------------------------------------
1 | require_dependency 'account_controller'
2 |
3 | module AccountControllerPatch
4 |
5 | def self.included(base)
6 | base.send(:include, InstanceMethods)
7 | base.class_eval do
8 | alias_method_chain :password_authentication, :sms_auth
9 | end
10 | end
11 |
12 | module InstanceMethods
13 |
14 | def sms_confirm
15 | if session[:sms_user_id] && session[:sms_password]
16 | user = User.find(session[:sms_user_id])
17 | if session[:sms_password] == params[:sms_password].to_s
18 | session[:sms_user_id] = nil
19 | session[:sms_password] = nil
20 | session[:sms_failed_attempts] = nil
21 | params[:back_url] = session[:sms_back_url]
22 | session[:sms_back_url] = nil
23 | successful_authentication(user)
24 | else
25 | session[:sms_failed_attempts] ||= 0
26 | session[:sms_failed_attempts] += 1
27 | if session[:sms_failed_attempts] >= 3
28 | regenerate_sms_password(user)
29 | flash[:error] = l(:notice_account_sms_limit_exceeded_failed_attempts)
30 | else
31 | flash[:error] = l(:notice_account_invalid_sms_password)
32 | end
33 | render 'sms'
34 | end
35 | else
36 | redirect_to '/'
37 | end
38 | end
39 |
40 | def sms_resend
41 | if session[:sms_user_id] && session[:sms_password]
42 | user = User.find(session[:sms_user_id])
43 | regenerate_sms_password(user)
44 | flash[:notice] = l(:notice_account_sms_resent_again)
45 | render 'sms'
46 | else
47 | redirect_to '/'
48 | end
49 | end
50 |
51 | private
52 |
53 | def password_authentication_with_sms_auth
54 | user = User.where(login: params[:username].to_s).first
55 | if user && user.auth_source && user.auth_source.auth_method_name == 'SMS' && !user.mobile_phone.blank?
56 | session[:sms_back_url] = params[:back_url]
57 | if User.try_to_login(params[:username], params[:password]) == user
58 | session[:sms_user_id] = user.id
59 | regenerate_sms_password(user)
60 | render 'sms'
61 | else
62 | invalid_credentials
63 | end
64 | else
65 | password_authentication_without_sms_auth
66 | end
67 | end
68 |
69 | def regenerate_sms_password(user)
70 | session[:sms_password] = SmsAuth.generate_sms_password
71 | SmsAuth.send_sms_password(user.mobile_phone, session[:sms_password])
72 | session[:sms_failed_attempts] = 0
73 | end
74 |
75 | end
76 |
77 | end
78 |
79 | AccountController.send(:include, AccountControllerPatch)
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](http://www.redmine.org/plugins/redmine_sms_auth)
2 |
3 | # Redmine SMS Auth
4 |
5 | This plugin is deprecated. Please use https://github.com/southbridgeio/redmine_2fa
6 |
7 | Plugin adds SMS-authentication to [Redmine](http://www.redmine.org/). Plugin compatible with Redmine 2.0.x and higher.
8 |
9 | Please help us make this plugin better telling us of any [issues](https://github.com/southbridgeio/redmine_sms_auth/issues) you'll face using it. We are ready to answer all your questions regarding this plugin.
10 |
11 | ## Some notes
12 |
13 | When you use SMS-authentication user should pass two steps: standard password checking and sms-password confirmation.
14 |
15 | If user's mobile phone is blank, plugin authenticates him with password checking only.
16 |
17 | When you create new user you should set user's password with 'Internal' authentication mode, save, change authentication mode to 'SMS'. Otherwise user will be created without password. You can use same way for user's password changing. User can change his password by himself too.
18 |
19 | Because there are many sms-gateways with different API, the responsibility on sending sms-message falls to external command. It can be any shell script or command like `curl`, e.g.
20 | ```
21 | curl http://my-sms-gateway.net?phone=%{phone}&message=%{password}
22 | ```
23 | `%{phone}` and `%{password}` are placeholders. They will be replaced with actual data during runtime. Default command is `echo %{phone} %{password}`.
24 |
25 | Default password length is 4.
26 |
27 | ## WARNING
28 |
29 | All **rake** commands must be run with correct **RAILS_ENV** variable, e.g.
30 | ```
31 | RAILS_ENV=production rake redmine:plugins:migrate
32 | ```
33 |
34 | ## Installation
35 |
36 | 1. Stop redmine
37 |
38 | 2. Clone repository to your redmine/plugins directory
39 | ```
40 | git clone git://github.com/southbridgeio/redmine_sms_auth.git
41 | ```
42 |
43 | 3. Run migration
44 | ```
45 | rake redmine:plugins:migrate
46 | ```
47 |
48 | 4. Add command for sms sending (and optionally password length) to configuration.yml
49 | ```yaml
50 | production:
51 | sms_auth:
52 | command: 'echo %{phone} %{password}'
53 | password_length: 5
54 | ```
55 |
56 | 5. Run redmine
57 |
58 | 6. Set 'Authentication mode' to 'SMS' for each user (Administration - Users)
59 | 7. (Optionally) Set mobile phone number for each user. Also user can set number for himself.
60 |
61 | ## Uninstall
62 |
63 | 1. Set 'Authentication mode' to 'Internal' for each user (Administration - Users)
64 |
65 | 2. Stop redmine.
66 |
67 | 3. Remove 'sms_auth' section from configuration.yml
68 |
69 | 4. Rollback migration
70 | ```
71 | rake redmine:plugins:migrate VERSION=0 NAME=redmine_sms_auth
72 | ```
73 |
74 | 5. Remove plugin directory from your redmine/plugins directory
75 |
76 | ## Sponsors
77 |
78 | Work on this plugin was fully funded by [Southbridge](https://southbridge.io)
79 |
--------------------------------------------------------------------------------
/test/functional/account_controller_patch_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../../test_helper', __FILE__)
2 |
3 | class AccountControllerPatchTest < ActionController::TestCase
4 |
5 | fixtures :users, :roles
6 |
7 | def setup
8 | @controller = AccountController.new
9 | @request = ActionController::TestRequest.new
10 | @response = ActionController::TestResponse.new
11 |
12 | auth_source = AuthSourceSms.create(name: 'SMS', onthefly_register: false, tls: false)
13 | User.current = nil
14 | user = User.find(2) # jsmith
15 | user.update_attribute :auth_source_id, auth_source.id
16 | end
17 |
18 | def test_login_with_wrong_password
19 | post :login, :username => 'jsmith', :password => 'bad'
20 | assert_response :success
21 | assert_template 'login'
22 |
23 | assert_select 'div.flash.error', :text => /Invalid user or password/
24 | assert_select 'input[name=username][value=jsmith]'
25 | assert_select 'input[name=password]'
26 | assert_select 'input[name=password][value]', 0
27 | end
28 |
29 | def test_login_without_sms_auth
30 | post :login, :username => 'dlopper', :password => 'foo'
31 | assert_redirected_to '/my/page'
32 | end
33 |
34 | def test_login_without_mobile_phone
35 | post :login, :username => 'jsmith', :password => 'jsmith'
36 | assert_redirected_to '/my/page'
37 | end
38 |
39 | def test_login_with_mobile_phone
40 | User.find(2).update_attribute :mobile_phone, '79999999999'
41 |
42 | SmsAuth.expects(:generate_sms_password).returns('1234')
43 | SmsAuth.expects(:send_sms_password).with('79999999999', '1234')
44 |
45 | post :login, :username => 'jsmith', :password => 'jsmith'
46 |
47 | assert_template 'sms'
48 | assert @request.session[:sms_user_id] == 2
49 | assert @request.session[:sms_password] == '1234'
50 | assert @request.session[:sms_failed_attempts] == 0
51 | end
52 |
53 | def test_login_with_back_url
54 | User.find(2).update_attribute :mobile_phone, '79999999999'
55 | post :login, :username => 'jsmith', :password => 'jsmith', back_url: 'http://localhost/somewhere'
56 | assert @request.session[:sms_back_url] == 'http://localhost/somewhere'
57 | end
58 |
59 | def test_sms_confirm_without_sms_user_id_in_session
60 | @request.session[:sms_user_id] = nil
61 | @request.session[:sms_password] = '1234'
62 | post :sms_confirm, sms_password: '1234'
63 | assert_redirected_to '/'
64 | end
65 |
66 | def test_sms_confirm_without_sms_password_in_session
67 | @request.session[:sms_user_id] = 2
68 | @request.session[:sms_password] = nil
69 | post :sms_confirm, sms_password: '1234'
70 | assert_redirected_to '/'
71 | end
72 |
73 | def test_sms_confirm_with_wrong_sms_password
74 | @request.session[:sms_user_id] = 2
75 | @request.session[:sms_password] = '1234'
76 | post :sms_confirm, sms_password: '12345'
77 |
78 | assert_template 'sms'
79 | assert_select 'div.flash.error', :text => /Wrong SMS confirmation password/
80 | end
81 |
82 | def test_sms_confirm_with_wrong_sms_password_limit_of_failed_attempts_exceeded
83 | User.find(2).update_attribute :mobile_phone, '79999999999'
84 |
85 | SmsAuth.expects(:generate_sms_password).returns('7890')
86 | SmsAuth.expects(:send_sms_password).with('79999999999', '7890')
87 |
88 | @request.session[:sms_user_id] = 2
89 | @request.session[:sms_password] = '1234'
90 | @request.session[:sms_failed_attempts] = 2
91 |
92 | post :sms_confirm, sms_password: '12345'
93 | assert_select 'div.flash.error', :text => /New password sent/
94 | assert @request.session[:sms_user_id] == 2
95 | assert @request.session[:sms_password] == '7890'
96 | assert @request.session[:sms_failed_attempts] == 0
97 | end
98 |
99 | def test_sms_confirm_with_correct_sms_password
100 | @request.session[:sms_user_id] = 2
101 | @request.session[:sms_password] = '1234'
102 | @request.session[:sms_back_url] = 'http://localhost/somewhere'
103 | post :sms_confirm, sms_password: '1234'
104 |
105 | assert_redirected_to '/my/page'
106 | assert User.current == User.find(2)
107 | assert @request.session[:sms_user_id] == nil
108 | assert @request.session[:sms_password] == nil
109 | assert @request.session[:sms_failed_attempts] == nil
110 | assert @request.session[:sms_back_url] == nil
111 | assert @request.params[:back_url] == 'http://localhost/somewhere'
112 | end
113 |
114 | def test_sms_resend_without_sms_user_id_in_session
115 | @request.session[:sms_user_id] = nil
116 | @request.session[:sms_password] = '1234'
117 | get :sms_resend
118 | assert_redirected_to '/'
119 | end
120 |
121 | def test_sms_resend_without_sms_password_in_session
122 | @request.session[:sms_user_id] = 2
123 | @request.session[:sms_password] = nil
124 | get :sms_resend
125 | assert_redirected_to '/'
126 | end
127 |
128 | def test_sms_resend
129 | User.find(2).update_attribute :mobile_phone, '79999999999'
130 | @request.session[:sms_user_id] = 2
131 | @request.session[:sms_password] = '1234'
132 |
133 | SmsAuth.expects(:generate_sms_password).returns('5678')
134 | SmsAuth.expects(:send_sms_password).with('79999999999', '5678')
135 |
136 | get :sms_resend
137 |
138 | assert_template 'sms'
139 | assert_select 'div.flash.notice', :text => /SMS confirmation password sent again/
140 | assert @request.session[:sms_user_id] == 2
141 | assert @request.session[:sms_password] == '5678'
142 | assert @request.session[:sms_failed_attempts] == 0
143 | end
144 |
145 | end
--------------------------------------------------------------------------------