├── .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 |
2 | <%= form_tag(sms_confirm_path) do %> 3 | <% if params[:autologin] %> 4 | <%= hidden_field_tag 'autologin', params[:autologin] %> 5 | <% end %> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 19 |
<%= text_field_tag :sms_password, nil, :autocomplete => 'off' %>
13 | <%= link_to l(:resend_sms), sms_resend_path %> 14 | 16 | 17 |
20 | <% end %> 21 |
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 | [![Rate at redmine.org](http://img.shields.io/badge/rate%20at-redmine.org-blue.svg?style=flat)](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 --------------------------------------------------------------------------------