diff --git a/.travis.yml b/.travis.yml index 03e783e..a22b46b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,9 @@ language: ruby rvm: - 2.0.0 - 2.1.0 +branches: + only: + - master gemfile: - Gemfile script: bundle exec rake spec diff --git a/CHANGELOG.md b/CHANGELOG.md index f256517..f2175ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,35 @@ +## 0.2.0 + +Remake the this library. + +#### Upgrade + +1. Run the following commands. + +``` +$ bundle update passwd +$ bundle exec rails gneratate passwd:config +``` + +2. Migrate your passwd settings to `config/initializers/passwd.rb`. +3. Updates your code! + +#### Changes + +- Add extention to ActiveController. + - Add `current_user`, `signin!` and `signout!` to ActionController. + - Add `require_signin` method for `before_action`. +- Include the `Passwd::ActiveRecord` was no longer needed. +- Rename method `define_column` to `with_authenticate` in your User model. +- Rename method `Passwd.create` to `Passwd.random`. +- Rename method `Passwd.hashing` to `Passwd.digest`. +- Add `passwd` method User class. Create Passwd::Password object from target user attributes. +- Split object password and salt. + ## 0.1.5 -Features: +#### Changes - Can be specified algorithm of hashing - Change default hashing algorithm to SHA512 from SHA1 + diff --git a/LICENSE.txt b/LICENSE.txt index be47849..737eabd 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2013 i2bskn +Copyright (c) 2013-2014 i2bskn MIT License @@ -20,3 +20,4 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md index 0941d7b..43a0102 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,169 @@ # Passwd -[![Gem Version](https://badge.fury.io/rb/passwd.png)](http://badge.fury.io/rb/passwd) -[![Build Status](https://travis-ci.org/i2bskn/passwd.png?branch=master)](https://travis-ci.org/i2bskn/passwd) -[![Coverage Status](https://coveralls.io/repos/i2bskn/passwd/badge.png?branch=master)](https://coveralls.io/r/i2bskn/passwd?branch=master) -[![Code Climate](https://codeclimate.com/github/i2bskn/passwd.png)](https://codeclimate.com/github/i2bskn/passwd) +[![Gem Version](https://badge.fury.io/rb/passwd.svg)](http://badge.fury.io/rb/passwd) +[![Build Status](https://travis-ci.org/i2bskn/passwd.svg?branch=master)](https://travis-ci.org/i2bskn/passwd) +[![Coverage Status](https://img.shields.io/coveralls/i2bskn/passwd.svg)](https://coveralls.io/r/i2bskn/passwd?branch=master) +[![Code Climate](https://codeclimate.com/github/i2bskn/passwd/badges/gpa.svg)](https://codeclimate.com/github/i2bskn/passwd) -Password utilities. +Password utilities and integration to Rails. ## Installation Add this line to your application's Gemfile: ```ruby -gem 'passwd' +gem "passwd" ``` And then execute: $ bundle -Or install it yourself as: - - $ gem install passwd - ## Usage -```ruby -require 'passwd' -``` +### ActiveRecord with Rails -### Create random password +Add authentication to your `User` model. +Model name is `User` by default, but can be changed in configuration file. ```ruby -password = Passwd.create +class User < ActiveRecord::Base + with_authenticate +end ``` -### Hashing password +#### Options -Hashing with SHA1. +User model The following column are required. +Column name can be changed with the specified options. -```ruby -password_hash = Passwd.hashing(password) -``` - -### Password settings +- `:id => :email` Unique value to be used for authentication. +- `:salt => :salt` Column of String to save the salt. +- `:password => :password` Column of String to save the hashed password. -Default config is stored in the class instance variable. -Changing the default configs are as follows: +Use the `name` column as id. ```ruby -Passwd.config # => Get config object. -Passwd.config(length: 10) # => Change to the default length. - -Passwd.configure do |c| - c.algorithm = :sha512 - c.length = 10 +class User < ActiveRecord::Base + with_authenticate id: :name end ``` -Options that can be specified: - -* :algorithm => Hashing algorithm. default is :sha512. -* :length => Number of characters. default is 8. -* :lower => Skip lower case if set false. default is true. -* :upper => Skip upper case if set false. default is true. -* :number => Skip numbers if set false. default is true. -* :letters_lower => Define an array of lower case. default is ("a".."z").to_a -* :letters_upper => Define an array of upper case. default is ("A".."Z").to_a -* :letters_number => Define an array of numbers. default is ("0".."9").to_a +#### Authenticate -### Policy check - -Default policy is 8 more characters and require lower case and require number. +`authenticate` method is available in both instance and class. +Returns user object if the authentication successful. +Returns nil if authentication fails or doesn't exists user. +Instance method is not required `id`. ```ruby -Passwd.policy_check("secret") # => true or false +user = User.authenticate(params[:email], params[:password]) # => return user object or nil. +user.authenticate(params[:password]) ``` -### Policy settings +`set_password` method will be set random password. +To specify password as an argument if you want to specify a password. ```ruby -Passwd.policy_configure do |c| - c.min_length = 10 -end -``` - -Options that can be specified: - -* :min_length => Number of minimum characters. default is 8. -* :require_lower => Require lower case if set true. default is true. -* :require_upper => Require upper case if set true. default is false. -* :require_number => Require number if set true. default is true. +current_user.set_password("secret") # => random password if not specified a argument. +current_user.passwd.plain # => new password +current_user.save -### Password object +new_user = User.new +password = new_user.passwd.plain +UserMailer.register(new_user, password).deliver! +``` -Default password is randomly generated. -Default salt is "#{Time.now.to_s}". +`update_password` method will be set new password if the authentication successful. +But `update_password` method doesn't call `save` method. ```ruby -password = Passwd::Password.new -password.text # return text password. -password.salt_text # return text salt. -password.salt_hash # return hash salt. -password.hash # return hash password. +# update_password(OLD_PASSWORD, NEW_PASSWORD[, POLICY_CHECK=false]) +current_user.update_password(old_pass, new_pass, true) +current_user.save ``` -Options that can be specified: - -* :password => Text password. default is random. -* :salt_text => Text salt. default is #{Time.now.to_s}. +#### Policy check -Password authenticate: +Default policy is 8 more characters and require lower case and require number. +Can be changed in configuration file. ```ruby -password = Passwd::Password.new -Passwd.auth(password.text, password.salt_hash, password.hash) # => true -Passwd.auth("invalid!!", password.salt_hash, password.hash) # => false - -password == password.text # => true -password == "invalid!!" # => false +Passwd.policy_check("secret") # => true or false ``` -## For ActiveRecord +### ActionController -### User model +Already several methods is available in your controller. -Include `Passwd::ActiveRecord` module and define id/salt/password column from `define_column` method. -`id` column is required uniqueness. +If you want to authenticate the application. +Unauthorized access is thrown exception. +Can be specified to redirect in configuration file. ```ruby -class User < ActiveRecord::Base - include Passwd::ActiveRecord - # if not specified arguments for define_column => {id: :email, salt: :salt, password: :password} - define_column id: :id_colname, salt: :salt_colname, password: :password_colname - - ... +class ApplicationController < ActionController::Base + before_action :require_signin end ``` -Available following method by defining id/salt/password column. - -### Authentication - -`authenticate` method is available in both instance and class. -Return the user object if the authentication successful. -Return the nil if authentication fails or doesn't exists user. +If you want to implement the session management. ```ruby -user = User.authenticate(params[:email], params[:password]) # => return user object or nil. - -if user - session[:user] = user.id - redirect_to bar_path, notice: "Hello #{user.name}!" -else - flash.now[:alert] = "Authentication failed" - render action: :new +class SessionsController < ApplicationController + # If you has been enabled `require_signin` in ApplicationController + skip_before_action :require_signin + + # GET /signin + def new; end + + # POST /signin + def create + # Returns nil or user + @user = User.authenticate(params[:email], params[:password]) + + if @user + # Save user_id to session + signin!(@user) + redirect_to some_url, notice: "Signin was successful. Hello #{current_user.name}" + else # Authentication fails + render action: :new + end + end + + # DELETE /signout + def destroy + # Clear session (Only user_id) + signout! + end end ``` -instance method is not required `id`. +`current_user` method available if already signin. ```ruby -current_user = User.find(session[:user]) - -if current_user.authenticate(params[:password]) # => return true or false - # some process - redirect_to bar_path, notice: "Some process is successfully" -else - flash.now[:alert] = "Authentication failed" - render action: :edit +# app/controllers/greet_controller.rb +def greet + render text: "Hello #{current_user.name}!!" end -``` -### Change passowrd +# app/views/greet/greet.html.erb +

Hello <%= current_user.name %>!!

+``` -`set_password` method will be set random password. -Return value is plain text password. -To specify the password as an argument if you want to specify a password. -`salt` also set if salt is nil. +### Generate configuration file -```ruby -current_user = User.find(session[:user]) -password_text = current_user.set_password +Run generator of Rails. +Configuration file created to `config/initializers/passwd.rb`. -if current_user.save - redirect_to bar_path, notice: "Password update successfully" -else - render action: :edit -end ``` - -`update_password` method will be set new password if the authentication successful. -But `update_password` method doesn't call `save` method. - -```ruby -current_user = User.find(session[:user]) - -begin - Passwd.confirm_check(params[:password], params[:password_confirmation]) - # update_password(OLD_PASSWORD, NEW_PASSWORD[, POLICY_CHECK=false]) - current_user.update_password(old_pass, new_pass, true) - current_user.save! - redirect_to bar_path, notice: "Password updated successfully" -rescue Passwd::PasswordNotMatch - # PASSWORD != PASSWORD_CONFIRMATION from Passwd.#confirm_check - flash.now[:alert] = "Password not match" - render action: :edit -rescue Passwd::AuthError - # Authentication failed from #update_password - flash.now[:alert] = "Password is incorrect" - render action: :edit -rescue Passwd::PolicyNotMatch - # Policy not match from #update_password - flash.now[:alert] = "Policy not match" - render action: :edit -rescue - # Other errors - flash.now[:alert] = "Password update failed" - render action: :edit -end +$ bundle exec rails generate passwd:config ``` ## Contributing -1. Fork it +1. Fork it ( https://github.com/i2bskn/passwd/fork ) 2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Added some feature'`) +3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +5. Create a new Pull Request + diff --git a/lib/generators/passwd/config_generator.rb b/lib/generators/passwd/config_generator.rb new file mode 100644 index 0000000..83a0cfa --- /dev/null +++ b/lib/generators/passwd/config_generator.rb @@ -0,0 +1,13 @@ +module Passwd + module Generators + class ConfigGenerator < ::Rails::Generators::Base + source_root File.expand_path(File.join(File.dirname(__FILE__), "templates")) + + desc "Create Passwd configuration file" + def create_configuration_file + template "passwd_config.rb", "config/initializers/passwd.rb" + end + end + end +end + diff --git a/lib/generators/passwd/templates/passwd_config.rb b/lib/generators/passwd/templates/passwd_config.rb new file mode 100644 index 0000000..160dfc0 --- /dev/null +++ b/lib/generators/passwd/templates/passwd_config.rb @@ -0,0 +1,41 @@ +Passwd.configure do |c| + # Password settings + # The following settings are all default values. + + # Hashing algorithm + # Supported algorithm is :md5, :rmd160, :sha1, :sha256, :sha384 and :sha512 + # c.algorithm = :sha512 + + # Random generate password length + # c.length = 8 + + # Number of hashed by stretching + # Not stretching if specified nil. + # c.stretching = nil + + # Character type that is used for password + # c.lower = true + # c.upper = true + # c.number = true +end + +Passwd.policy_configure do |c| + # Minimum password length + # c.min_length = 8 + + # Character types to force the use + # c.require_lower = true + # c.require_upper = false + # c.require_number = true +end + +# Session key for authentication +Rails.application.config.passwd.session_key = :user_id + +# Authentication Model Class +Rails.application.config.passwd.authenticate_class = :User + +# Redirect path when not signin +# E.G. :signin_path # Do not specify ***_url +Rails.application.config.passwd.redirect_to = nil + diff --git a/lib/passwd.rb b/lib/passwd.rb index 610ab19..996e0db 100644 --- a/lib/passwd.rb +++ b/lib/passwd.rb @@ -3,7 +3,24 @@ require "passwd/version" require "passwd/errors" +require "passwd/policy" +require "passwd/configuration" require "passwd/base" +require "passwd/salt" require "passwd/password" -require "passwd/active_record" +require "passwd/railtie" + +module Passwd + include Configuration::Accessible + extend Configuration::Writable + extend Base + + def self.policy_check(plain) + Password.from_plain(plain).valid? + end + + def self.match?(plain, salt_hash, hash) + Password.from_hash(hash, salt_hash).match?(plain) + end +end diff --git a/lib/passwd/action_controller_ext.rb b/lib/passwd/action_controller_ext.rb new file mode 100644 index 0000000..750c33a --- /dev/null +++ b/lib/passwd/action_controller_ext.rb @@ -0,0 +1,48 @@ +module Passwd + module ActionControllerExt + extend ActiveSupport::Concern + + included do + helper_method :current_user + end + + def current_user + @current_user ||= auth_class.find_by(id: session[auth_key]) + end + + def signin!(user) + @current_user = user + session[auth_key] = user.id + end + + def signout! + @current_user = session[auth_key] = nil + end + + private + def auth_key + Rails.application.config.passwd.session_key || :user_id + end + + def auth_class + @_auth_class ||= + (Rails.application.config.passwd.authenticate_class || :User).to_s.constantize + end + + def _redirect_path + _to = Rails.application.config.passwd.redirect_to + _to ? Rails.application.routes.url_helpers.send(_to) : nil + end + + def require_signin + unless current_user + if _redirect_path + redirect_to _redirect_path + else + raise UnauthorizedAccess + end + end + end + end +end + diff --git a/lib/passwd/active_record.rb b/lib/passwd/active_record.rb deleted file mode 100644 index acbe20d..0000000 --- a/lib/passwd/active_record.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Passwd - module ActiveRecord - module ClassMethods - def define_column(options={}) - id_name = options.fetch(:id, :email) - salt_name = options.fetch(:salt, :salt) - password_name = options.fetch(:password, :password) - - define_singleton_auth(id_name, salt_name, password_name) - define_instance_auth(id_name, salt_name, password_name) - define_set_password(id_name, salt_name, password_name) - define_update_password(salt_name, password_name) - end - - private - def define_singleton_auth(id_name, salt_name, password_name) - define_singleton_method :authenticate do |id, pass| - user = self.where(id_name => id).first - user if user && Passwd.auth(pass, user.send(salt_name), user.send(password_name)) - end - end - - def define_instance_auth(id_name, salt_name, password_name) - define_method :authenticate do |pass| - Passwd.auth(pass, self.send(salt_name), self.send(password_name)) - end - end - - def define_set_password(id_name, salt_name, password_name) - define_method :set_password do |pass=nil| - password = pass || Passwd.create - salt = self.send(salt_name) || Passwd.hashing("#{self.send(id_name)}#{Time.now.to_s}") - self.send("#{salt_name.to_s}=", salt) - self.send("#{password_name.to_s}=", Passwd.hashing("#{salt}#{password}")) - password - end - end - - def define_update_password(salt_name, password_name) - define_method :update_password do |old_pass, new_pass, policy_check=false| - if Passwd.auth(old_pass, self.send(salt_name), self.send(password_name)) - if policy_check - raise Passwd::PolicyNotMatch, "Policy not match" unless Passwd.policy_check(new_pass) - end - - set_password(new_pass) - else - raise Passwd::AuthError - end - end - end - end - - class << self - def included(base) - base.extend ClassMethods - end - end - end -end - diff --git a/lib/passwd/active_record_ext.rb b/lib/passwd/active_record_ext.rb new file mode 100644 index 0000000..79fa8fc --- /dev/null +++ b/lib/passwd/active_record_ext.rb @@ -0,0 +1,65 @@ +module Passwd + module ActiveRecordExt + def with_authenticate(options = {}) + _id_key = options.fetch(:id, :email) + _salt_key = options.fetch(:salt, :salt) + _pass_key = options.fetch(:password, :password) + + _define_passwd(_salt_key, _pass_key) + _define_singleton_auth(_id_key) + _define_instance_auth + _define_set_password(_salt_key, _pass_key) + _define_update_password(_salt_key, _pass_key) + end + + private + def _define_passwd(_salt_key, _pass_key) + define_method :passwd do |cache = true| + return @_passwd if cache && @_passwd + self.reload + _salt, _pass = self.send(_salt_key), self.send(_pass_key) + if _salt.present? && _pass.present? + @_passwd = Passwd::Password.from_hash(_pass, _salt) + else + self.set_password + end + end + end + + def _define_singleton_auth(_id_key) + define_singleton_method :authenticate do |_id, _pass| + _condition = Array(_id_key).map {|k| "#{k} = :id"}.join(" OR ") + _user = self.find_by(_condition, id: _id) + _user if _user && _user.passwd.match?(_pass) + end + end + + def _define_instance_auth + define_method :authenticate do |_pass| + self.passwd.match?(_pass) + end + end + + def _define_set_password(_salt_key, _pass_key) + define_method :set_password do |_pass = nil| + _options = _pass ? {plain: _pass} : {} + _passwd = Passwd::Password.new(_options) + self.send("#{_salt_key}=", _passwd.salt.hash) + self.send("#{_pass_key}=", _passwd.hash) + self.instance_variable_set(:@_passwd, _passwd) + end + end + + def _define_update_password(_salt_key, _pass_key) + define_method :update_password do |_old, _new, _policy = false| + raise PolicyNotMatch if _policy && !Passwd.policy_check(_new) + if self.passwd.match?(_old) + self.set_password(_new) + else + raise AuthenticationFails + end + end + end + end +end + diff --git a/lib/passwd/base.rb b/lib/passwd/base.rb index 2a0fe83..82e7be5 100644 --- a/lib/passwd/base.rb +++ b/lib/passwd/base.rb @@ -1,75 +1,31 @@ -require "singleton" -require "passwd/configuration/config" -require "passwd/configuration/tmp_config" -require "passwd/configuration/policy" - module Passwd - @config = Config.instance - @policy = Policy.instance - module Base - def create(options={}) - if options.empty? - config = @config - else - config = TmpConfig.new(@config, options) - end - Array.new(config.length){config.letters[rand(config.letters.size)]}.join - end - - def auth(password_text, salt_hash, password_hash) - enc_pass = Passwd.hashing("#{salt_hash}#{password_text}") - password_hash == enc_pass - end - - def hashing(plain, algorithm=nil) - if algorithm.nil? - eval "Digest::#{@config.algorithm.to_s.upcase}.hexdigest \"#{plain}\"" - else - eval "Digest::#{algorithm.to_s.upcase}.hexdigest \"#{plain}\"" - end + def random(options = {}) + c = Config.merge(options) + Array.new(c.length){c.letters[rand(c.letters.size)]}.join end - def confirm_check(password, confirm, with_policy=false) - raise PasswordNotMatch, "Password not match" if password != confirm - return true unless with_policy - Passwd.policy_check(password) - end + def digest(plain, _algorithm = nil) + _algorithm ||= Config.algorithm - def configure(options={}, &block) - if block_given? - @config.configure &block - else - if options.empty? - @config - else - @config.merge options + if Config.stretching + _pass = plain + Config.stretching.times do + _pass = digest_without_stretching(_pass, _algorithm) end - end - end - alias :config :configure - - def policy_configure(&block) - if block_given? - @policy.configure &block else - @policy + digest_without_stretching(plain, _algorithm) end end - def policy_check(password) - @policy.valid?(password, @config) + def digest_without_stretching(plain, _algorithm = nil) + algorithm(_algorithm || Config.algorithm).hexdigest(plain) end - def reset_config - @config.reset - end - - def reset_policy - @policy.reset - end + private + def algorithm(_algorithm) + Digest.const_get(_algorithm.upcase, false) + end end - - extend Base end diff --git a/lib/passwd/configuration.rb b/lib/passwd/configuration.rb new file mode 100644 index 0000000..f84c4b4 --- /dev/null +++ b/lib/passwd/configuration.rb @@ -0,0 +1,76 @@ +module Passwd + class Configuration + KINDS = %i(lower upper number).freeze + LETTERS = KINDS.map {|k| "letters_#{k}".to_sym}.freeze + + VALID_OPTIONS = [ + :algorithm, + :length, + :policy, + :stretching, + ].concat(KINDS).concat(LETTERS).freeze + + attr_accessor *VALID_OPTIONS + + def initialize + reset + end + + def configure + yield self + end + + def merge(params) + self.dup.merge!(params) + end + + def merge!(params) + params.keys.each do |key| + self.send("#{key}=", params[key]) + end + self + end + + KINDS.each do |kind| + define_method "#{kind}_chars" do + self.send("letters_#{kind}") || [] + end + end + + def letters + KINDS.detect {|k| self.send(k)} || (raise ConfigError, "letters is empry.") + LETTERS.map {|l| self.send(l)}.flatten + end + + def reset + self.algorithm = :sha512 + self.length = 8 + self.policy = Policy.new + self.stretching = nil + self.lower = true + self.upper = true + self.number = true + self.letters_lower = ("a".."z").to_a + self.letters_upper = ("A".."Z").to_a + self.letters_number = ("0".."9").to_a + end + + module Writable + def configure(options = {}, &block) + Config.merge!(options) unless options.empty? + Config.configure(&block) if block_given? + end + + def policy_configure(&block) + Config.policy.configure(&block) if block_given? + end + end + + module Accessible + def self.included(base) + base.const_set(:Config, Configuration.new) + end + end + end +end + diff --git a/lib/passwd/configuration/abstract_config.rb b/lib/passwd/configuration/abstract_config.rb deleted file mode 100644 index 8233e78..0000000 --- a/lib/passwd/configuration/abstract_config.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Passwd - class AbstractConfig - VALID_OPTIONS_KEYS = [ - :algorithm, - :length, - :lower, - :upper, - :number, - :letters_lower, - :letters_upper, - :letters_number - ].freeze - - attr_accessor *VALID_OPTIONS_KEYS - - def configure - yield self - end - - def merge(configs) - configs.keys.each do |k| - send("#{k}=", configs[k]) - end - end - - def letters - chars = [] - chars.concat(self.letters_lower) if self.lower - chars.concat(self.letters_upper) if self.upper - chars.concat(self.letters_number) if self.number - raise "letters is empty" if chars.empty? - chars - end - end -end - diff --git a/lib/passwd/configuration/config.rb b/lib/passwd/configuration/config.rb deleted file mode 100644 index b04a87b..0000000 --- a/lib/passwd/configuration/config.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "passwd/configuration/abstract_config" - -module Passwd - class Config < AbstractConfig - include Singleton - - def initialize - reset - end - - def reset - self.algorithm = :sha512 - self.length = 8 - self.lower = true - self.upper = true - self.number = true - self.letters_lower = ("a".."z").to_a - self.letters_upper = ("A".."Z").to_a - self.letters_number = ("0".."9").to_a - end - end -end - diff --git a/lib/passwd/configuration/policy.rb b/lib/passwd/configuration/policy.rb deleted file mode 100644 index 8e1472d..0000000 --- a/lib/passwd/configuration/policy.rb +++ /dev/null @@ -1,45 +0,0 @@ -module Passwd - class Policy - include Singleton - - VALID_OPTIONS_KEYS = [ - :min_length, - :require_lower, - :require_upper, - :require_number - ].freeze - - attr_accessor *VALID_OPTIONS_KEYS - - def initialize - reset - end - - def configure - yield self - end - - def valid?(password, config) - return false if self.min_length > password.size - return false if self.require_lower && !include_char?(config.letters_lower, password) - return false if self.require_upper && !include_char?(config.letters_upper, password) - return false if self.require_number && !include_char?(config.letters_number, password) - true - end - - def include_char?(letters, strings) - strings.each_char do |c| - return true if letters.include? c - end - false - end - - def reset - self.min_length = 8 - self.require_lower = true - self.require_upper = false - self.require_number = true - end - end -end - diff --git a/lib/passwd/configuration/tmp_config.rb b/lib/passwd/configuration/tmp_config.rb deleted file mode 100644 index 0031566..0000000 --- a/lib/passwd/configuration/tmp_config.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "passwd/configuration/abstract_config" - -module Passwd - class TmpConfig < AbstractConfig - def initialize(config, options) - config.instance_variables.each do |v| - key = v.to_s.sub(/^@/, "").to_sym - if options.has_key? key - instance_variable_set v, options[key] - else - instance_variable_set v, config.instance_variable_get(v) - end - end - end - end -end - diff --git a/lib/passwd/errors.rb b/lib/passwd/errors.rb index bb8b7da..4e1c0d8 100644 --- a/lib/passwd/errors.rb +++ b/lib/passwd/errors.rb @@ -1,14 +1,8 @@ module Passwd - class PasswdError < StandardError - end - - class AuthError < PasswdError - end - - class PasswordNotMatch < PasswdError - end - - class PolicyNotMatch < PasswdError - end + class PasswdError < StandardError; end + class UnauthorizedAccess < PasswdError; end + class PolicyNotMatch < PasswdError; end + class AuthenticationFails < PasswdError; end + class ConfigError < PasswdError; end end diff --git a/lib/passwd/password.rb b/lib/passwd/password.rb index e62008d..6fe296e 100644 --- a/lib/passwd/password.rb +++ b/lib/passwd/password.rb @@ -1,40 +1,89 @@ module Passwd class Password - attr_reader :text, :hash, :salt_text, :salt_hash + include Base - def initialize(options={}) - @text = options.fetch(:password, Passwd.create) - @salt_text = options.fetch(:salt_text, Time.now.to_s) - @salt_hash = Passwd.hashing(@salt_text) - @hash = Passwd.hashing("#{@salt_hash}#{@text}") + attr_reader :plain, :hash, :salt + + def initialize(options = {}) + options = default_options.merge(options) + + if options.has_key?(:hash) + raise ArgumentError unless options.has_key?(:salt_hash) + @salt = Salt.from_hash(options[:salt_hash], self) + @hash = options[:hash] + else + @salt = + case + when options.has_key?(:salt_hash) + Salt.from_hash(options[:salt_hash], self) + when options.has_key?(:salt_plain) + Salt.from_plain(options[:salt_plain], self) + else + Salt.new(password: self) + end + self.plain = options[:plain] + end + end + + def plain=(value) + @plain = value + rehash + end + + def hash=(value, salt_hash) + @plain = nil + @hash = value + self.salt.hash = salt_hash + end + + def rehash + raise PasswdError unless self.plain + @hash = digest([self.salt.hash, self.plain].join) end - def text=(password) - @hash = Passwd.hashing("#{@salt_hash}#{password}") - @text = password + def match?(value) + self.hash == digest([self.salt.hash, value].join) end - def hash=(password_hash) - @text = nil - @hash = password_hash + def ==(other) + match?(other) end - def salt_text=(salt_text) - @salt_hash = Passwd.hashing(salt_text) - @hash = Passwd.hashing("#{@salt_hash}#{@text}") - @salt_text = salt_text + def to_s + self.plain.to_s end - def salt_hash=(salt_hash) - @salt_text = nil - @hash = Passwd.hashing("#{salt_hash}#{@text}") - @salt_hash = salt_hash + def valid? + raise PasswdError unless self.plain + + return false if Config.policy.min_length > self.plain.size + + Configuration::KINDS.each do |key| + if Config.policy.send("require_#{key}") + return false unless include_char?(Config.send("letters_#{key}")) + end + end + true end - def ==(password) - enc_pass = Passwd.hashing("#{@salt_hash}#{password}") - @hash == enc_pass + def self.from_plain(value, options = {}) + new(options.merge(plain: value)) end + + def self.from_hash(value, salt_hash) + new(hash: value, salt_hash: salt_hash) + end + + private + def default_options + {plain: random} + end + + def include_char?(letters) + raise PasswdError unless self.plain + self.plain.chars.uniq.each {|c| return true if letters.include?(c)} + false + end end end diff --git a/lib/passwd/policy.rb b/lib/passwd/policy.rb new file mode 100644 index 0000000..bcf5643 --- /dev/null +++ b/lib/passwd/policy.rb @@ -0,0 +1,28 @@ +module Passwd + class Policy + VALID_OPTIONS = [ + :min_length, + :require_lower, + :require_upper, + :require_number + ].freeze + + attr_accessor *VALID_OPTIONS + + def initialize + reset + end + + def configure + yield self + end + + def reset + self.min_length = 8 + self.require_lower = true + self.require_upper = false + self.require_number = true + end + end +end + diff --git a/lib/passwd/railtie.rb b/lib/passwd/railtie.rb new file mode 100644 index 0000000..a13deda --- /dev/null +++ b/lib/passwd/railtie.rb @@ -0,0 +1,19 @@ +module Passwd + class Railtie < ::Rails::Railtie + config.passwd = ActiveSupport::OrderedOptions.new + + initializer "passwd" do + require "passwd/action_controller_ext" + require "passwd/active_record_ext" + + ActiveSupport.on_load(:action_controller) do + ::ActionController::Base.send(:include, Passwd::ActionControllerExt) + end + + ActiveSupport.on_load(:active_record) do + ::ActiveRecord::Base.send(:extend, Passwd::ActiveRecordExt) + end + end + end +end + diff --git a/lib/passwd/salt.rb b/lib/passwd/salt.rb new file mode 100644 index 0000000..2c3c5a0 --- /dev/null +++ b/lib/passwd/salt.rb @@ -0,0 +1,50 @@ +module Passwd + class Salt + include Base + + attr_reader :plain, :hash + + def initialize(options = {}) + options = default_options.merge(options) + + @password = options[:password] + if options.has_key?(:hash) + @plain = nil + @hash = options[:hash] + else + @plain = options[:plain] || (raise ArgumentError) + @hash = digest_without_stretching(@plain) + end + end + + def plain=(value) + @plain = value + @hash = digest_without_stretching(@plain) + update_password! + end + + def hash=(value) + @plain = nil + @hash = value + update_password! + end + + def self.from_plain(value, password = nil) + new(plain: value, password: password) + end + + def self.from_hash(value, password = nil) + new(hash: value, password: password) + end + + private + def default_options + {plain: Time.now.to_s} + end + + def update_password! + @password.rehash if @password + end + end +end + diff --git a/passwd.gemspec b/passwd.gemspec index 36191f9..9a845a8 100644 --- a/passwd.gemspec +++ b/passwd.gemspec @@ -17,9 +17,10 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_development_dependency "bundler", "~> 1.3" + spec.add_development_dependency "bundler" spec.add_development_dependency "rake" spec.add_development_dependency "rspec" spec.add_development_dependency "coveralls" spec.add_development_dependency "simplecov" end + diff --git a/spec/passwd/active_record_spec.rb b/spec/passwd/active_record_spec.rb index 135f0be..bc74017 100644 --- a/spec/passwd/active_record_spec.rb +++ b/spec/passwd/active_record_spec.rb @@ -43,23 +43,23 @@ class User let!(:record) { record = double("record mock") allow(record).to receive_messages(salt: salt, password: password_hash) - response = [record] - allow(User).to receive(:where) {response} + response = record + allow(User).to receive(:find_by) {response} record } it "user should be returned if authentication is successful" do - expect(User).to receive(:where) + expect(User).to receive(:find_by) expect(User.authenticate("valid_id", password_text)).to eq(record) end it "should return nil if authentication failed" do - expect(User).to receive(:where) + expect(User).to receive(:find_by) expect(User.authenticate("valid_id", "invalid_secret")).to be_nil end it "should return nil if user not found" do - expect(User).to receive(:where).with(email: "invalid_id") {[]} + expect(User).to receive(:find_by).with(email: "invalid_id") {nil} expect(User.authenticate("invalid_id", password_text)).to be_nil end end