password_breach_alert 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +141 -0
  4. data/Rakefile +17 -0
  5. data/app/views/devise/mailer/password_breach_alert.html.erb +27 -0
  6. data/app/views/devise/mailer/password_breach_alert.text.erb +24 -0
  7. data/lib/common_password/devise.rb +10 -0
  8. data/lib/common_password/list.rb +15 -0
  9. data/lib/common_password/model.rb +48 -0
  10. data/lib/common_password/passwords.txt +9999 -0
  11. data/lib/devise/password_breach_alert.rb +32 -0
  12. data/lib/devise/password_breach_alert/locales/en.yml +34 -0
  13. data/lib/devise/password_breach_alert/locales/it.yml +34 -0
  14. data/lib/devise/password_breach_alert/model.rb +25 -0
  15. data/lib/generators/password_breach_alert_generator.rb +13 -0
  16. data/lib/generators/templates/create_breaches.rb +19 -0
  17. data/lib/password_breach_alert.rb +9 -0
  18. data/lib/password_breach_alert/api/base.rb +39 -0
  19. data/lib/password_breach_alert/api/breach.rb +15 -0
  20. data/lib/password_breach_alert/api/breachedaccount.rb +18 -0
  21. data/lib/password_breach_alert/breaches_filters.rb +8 -0
  22. data/lib/password_breach_alert/breaches_filters/after_user_last_checked_at.rb +34 -0
  23. data/lib/password_breach_alert/breaches_filters/all_with_user.rb +21 -0
  24. data/lib/password_breach_alert/breaches_filters/new_with_user.rb +21 -0
  25. data/lib/password_breach_alert/breaches_policies.rb +6 -0
  26. data/lib/password_breach_alert/breaches_policies/send_devise_notification.rb +12 -0
  27. data/lib/password_breach_alert/checker.rb +45 -0
  28. data/lib/password_breach_alert/mailer.rb +9 -0
  29. data/lib/password_breach_alert/models/breach.rb +55 -0
  30. data/lib/password_breach_alert/rails.rb +10 -0
  31. data/lib/password_breach_alert/railtie.rb +4 -0
  32. data/lib/password_breach_alert/version.rb +3 -0
  33. data/lib/pwned_password/devise.rb +13 -0
  34. data/lib/pwned_password/hooks.rb +6 -0
  35. data/lib/pwned_password/model.rb +66 -0
  36. data/lib/tasks/password_breach_alert.rake +15 -0
  37. data/lib/zxcvbn_password/devise.rb +20 -0
  38. data/lib/zxcvbn_password/email_tokeniser.rb +7 -0
  39. data/lib/zxcvbn_password/errors.rb +2 -0
  40. data/lib/zxcvbn_password/model.rb +84 -0
  41. data/lib/zxcvbn_password/tester.rb +36 -0
  42. metadata +261 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'devise'
4
+ require 'zxcvbn_password/devise'
5
+ require 'common_password/devise'
6
+ require 'pwned_password/devise'
7
+ require 'devise/password_breach_alert/model'
8
+ require 'password_breach_alert/breaches_policies'
9
+ require 'password_breach_alert/breaches_filters'
10
+
11
+ module Devise
12
+ extend ZxcvbnPassword::Devise
13
+ extend CommonPassword::Devise
14
+ extend PwnedPassword::Devise
15
+ mattr_accessor :pwned_active, :common_active, :zxcvbn_active
16
+ @@pwned_active = true
17
+ @@common_active = true
18
+ @@zxcvbn_active = true
19
+
20
+ mattr_accessor :breaches_policy, :breaches_filter, :breaches_filter_options
21
+ @@breaches_policy = 'PasswordBreachAlert::BreachesPolicies::SendDeviseNotification'
22
+ @@breaches_filter = 'PasswordBreachAlert::BreachesFilters::NewWithUser'
23
+ @@breaches_filter_options = -> { {} }
24
+
25
+ module PasswordBreachAlert
26
+ end
27
+ end
28
+
29
+ # Load default I18n
30
+ #
31
+ I18n.load_path.unshift File.join(File.dirname(__FILE__), 'password_breach_alert', 'locales', 'en.yml')
32
+ I18n.load_path.unshift File.join(File.dirname(__FILE__), 'password_breach_alert', 'locales', 'it.yml')
@@ -0,0 +1,34 @@
1
+ en:
2
+ activerecord:
3
+ attributes:
4
+ password_breach_alert/models/breach:
5
+ name: Name
6
+ title: Title
7
+ domain: Domain
8
+ breach_date: "Date of the breach"
9
+ added_date: "Date of the publication of the breach"
10
+ pwn_count: "Password count"
11
+ description: "Description"
12
+ logo_path: "Logo"
13
+ data_classes: "Classes of data breached"
14
+ devise:
15
+ mailer:
16
+ password_breach_alert:
17
+ subject: "Password breach alert"
18
+ hello: "Hello %{email},"
19
+ there_was_some_breach:
20
+ one: "We did some routine checks, and found out that an external service had a leak of sensitive data. It is possibile that amongs the violated credentials also those of your account are present. This is the info we recovered:"
21
+ other: "We did some routine checks, and found out that some external services had a leak of sensitive data. It is possibile that amongs the violated credentials also those of your account are present. This is the info we recovered:"
22
+ this_site_is_secure: "%{domain} is secure and we proactively work to avoid that other sites breaches could compromise users registered on %{domain}."
23
+ please_change_password: "For this reason, we suggest you to change your password, just as a precaution. It is possible that this is not necessary, but we'd rather bother you five minutes than risk that your account on %{domain} gets violated too."
24
+ errors:
25
+ messages:
26
+ common:
27
+ top100: 'The password you choose is extremely common: it appears in the list of the 100 most used password. To choose a secure password, you could compose togheter different words, possibly not too common. For example, "pepperoni" is easy too, but "pepperonipeskystaple" is easy to remember and hard to guess'
28
+ top1000: 'The password you choose is very common: it appears in the list of the 1000 most used password. To choose a more secure password, you could add a word or two. For example, "pepperoni" is too easy, but "pepperonipeskystaple" is easy to remember and hard to guess'
29
+ top10000: 'The password you choose is quite common. To choose a more secure password, you could add a word or two. For example, "pepperoni" is too easy, but "pepperonipeskystaple" is easy to remember and hard to guess'
30
+ pwned: "The password you choose has previously appeared in a data breach: this means that is insecure and should never be used. Please choose something harder to guess. For example, you can combine togheter different uncommon words."
31
+ zxcvbn: 'The password you choose is too easy to guess. In a scale from 0 to 4 it scores %{score}: an algorithm could guess it in %{crack_time_display}. To choose a more secure password, you could add a word or two. For example, "pepperoni" is too easy, but "pepperonipeskystaple" is easy to remember and hard to guess.'
32
+ time:
33
+ formats:
34
+ date_only: '%-d %b %Y'
@@ -0,0 +1,34 @@
1
+ it:
2
+ activerecord:
3
+ attributes:
4
+ password_breach_alert/models/breach:
5
+ name: Nome
6
+ title: Titolo
7
+ domain: Domino
8
+ breach_date: "Data dell'intrusione"
9
+ added_date: "Data dell'aggiunta"
10
+ pwn_count: "Numero di password"
11
+ description: "Descrizione"
12
+ logo_path: "Logo"
13
+ data_classes: "Tipo di dati trapelati"
14
+ devise:
15
+ mailer:
16
+ password_breach_alert:
17
+ subject: "Password breach alert"
18
+ hello: "Caro %{email},"
19
+ there_was_some_breach:
20
+ one: "Abbiamo fatto alcuni controlli di routine, e scoperto che un servizio esterno ha avuto una perdita di dati sensibili. Da un primo controllo, è possibile che tra le credenziali violate siano presenti anche quelle del tuo account. Ecco l'informazione che abbiamo recuperato:"
21
+ other: "Abbiamo fatto alcuni controlli di routine, e scoperto che alcuni servizi esterni hanno avuto una perdita di dati sensibili. Da un primo controllo, è possibile che tra le credenziali violate siano presenti anche quelle del tuo account. Ecco l'informazione che abbiamo recuperato:"
22
+ this_site_is_secure: "%{domain} è sicuro e ci attiviamo proattivamente per evitare che le brecce di altri siti possano compromettere gli account registrati su %{domain}."
23
+ please_change_password: "Per questo motivo ti suggeriamo di cambiare la tua password, per precauzione. E' possibile che non sia necessario, ma preferiamo disturbarti cinque minuti piuttosto che rischiare che anche il tuo account su %{domain} venga violato."
24
+ errors:
25
+ messages:
26
+ common:
27
+ top100: 'La password che hai scelto è estremamente comune: compare nella lista delle 100 password più usate al mondo. Per scegliere una buona password potresti comporre insieme diverse parole, se possibili non troppo comuni. Ad esempio, anche "ananas" è una password troppo semplice, mentre "pizzamozzarellaananas" è facile da ricordare e difficile da indovinare'
28
+ top1000: 'La password che hai scelto è molto comune: compare nella lista delle 1000 password più usate al mondo. Per renderla più sicura, potresti aggiungere una o due parole. Ad esempio, "ananas" è una password troppo semplice, mentre "pizzamozzarellaananas" è facile da ricordare e difficile da indovinare'
29
+ top10000: 'La password che hai scelto è abbastanza comune. Per renderla più sicura, potresti aggiungere una o due parole. Ad esempio, "ananas" è una password troppo semplice, mentre "pizzamozzarellaananas" è facile da ricordare e difficile da indovinare'
30
+ pwned: "La password che hai scelto è presente in un database di password violate: questo significa che è insicura e non dovrebbe essere usata. Per favore, scegli una password più difficile da indovinare. Ad esempio, potresti combinare insieme alcune parole poco comuni."
31
+ zxcvbn: 'La password che hai scelto è troppo facile. In una scala da 0 a 4 la sua difficoltà è %{score}: un algoritmo la potrebbe indovinare in %{crack_time_display}. Per renderla più sicura, potresti aggiungere una o due parole. Ad esempio, "ananas" è una password troppo semplice, mentre "pizzamozzarellaananas" è facile da ricordare e difficile da indovinare.'
32
+ time:
33
+ formats:
34
+ date_only: '%-d %b %Y'
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zxcvbn_password/model'
4
+ require 'common_password/model'
5
+ require 'pwned_password/model'
6
+
7
+ module Devise
8
+ module Models
9
+ module PasswordBreachAlert
10
+ extend ActiveSupport::Concern
11
+ include ZxcvbnPassword::Model
12
+ include CommonPassword::Model
13
+ include PwnedPassword::Model
14
+
15
+ module ClassMethods
16
+ ::Devise::Models.config(self, :pwned_active)
17
+ ::Devise::Models.config(self, :common_active)
18
+ ::Devise::Models.config(self, :zxcvbn_active)
19
+ ::Devise::Models.config(self, :breaches_policy)
20
+ ::Devise::Models.config(self, :breaches_filter)
21
+ ::Devise::Models.config(self, :breaches_filter_options)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ class PasswordBreachAlertGenerator < Rails::Generators::Base
2
+ include Rails::Generators::Migration
3
+
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ def create_breaches_migration
7
+ time_now = Time.current.strftime('%Y%m%d%H%M%S')
8
+ template_name = 'create_breaches'
9
+ migration_file = "#{time_now}_#{template_name}"
10
+
11
+ copy_file "#{template_name}.rb", File.join('db', 'migrate', "#{migration_file}.rb")
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ class CreateBreaches < ActiveRecord::Migration[5.2]
2
+ def change
3
+ create_table :breaches do |t|
4
+ t.string :name
5
+ t.string :title
6
+ t.string :domain
7
+ t.datetime :breach_date
8
+ t.datetime :added_date
9
+ t.integer :pwn_count
10
+ t.string :description
11
+ t.string :logo_path
12
+ t.text :data_classes
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :breaches, :added_date
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ module PasswordBreachAlert
2
+ autoload :Mailer, 'password_breach_alert/mailer'
3
+ end
4
+
5
+ require 'password_breach_alert/railtie'
6
+ require 'password_breach_alert/rails'
7
+ require 'password_breach_alert/checker'
8
+ require 'password_breach_alert/models/breach'
9
+ require 'devise/password_breach_alert'
@@ -0,0 +1,39 @@
1
+ module PasswordBreachAlert
2
+ module Api
3
+ # TODO: add some lock
4
+
5
+ module LastCalledAt
6
+ mattr_accessor :last_called_at
7
+ end
8
+
9
+ SLEEP_DURATION = 1.6.seconds
10
+
11
+ class Base
12
+ include LastCalledAt
13
+
14
+ DEFAULT_REQUEST_OPTIONS = {
15
+ 'User-Agent' => 'PasswordBreachAlert'
16
+ }.freeze
17
+
18
+ def initialize(request_options = {})
19
+ @request_options = DEFAULT_REQUEST_OPTIONS.merge(request_options)
20
+ end
21
+
22
+ def with_wait
23
+ return enum_for :with_wait unless block_given?
24
+
25
+ # TODO: with_advisory_lock('PasswordBreachAlert::Api') do
26
+ if last_called_at && last_called_at > SLEEP_DURATION.ago
27
+ sleep(last_called_at - SLEEP_DURATION.ago)
28
+ end
29
+
30
+ result = yield
31
+
32
+ self.last_called_at = Time.current
33
+ # TODO: end
34
+
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ require 'password_breach_alert/api/base'
2
+
3
+ module PasswordBreachAlert
4
+ module Api
5
+ class Breach < PasswordBreachAlert::Api::Base
6
+ API_URL = 'https://haveibeenpwned.com/api/v2/breaches'.freeze
7
+
8
+ def call
9
+ with_wait do
10
+ JSON.parse(URI.parse(API_URL).open(@request_options).read)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ require 'password_breach_alert/api/base'
2
+
3
+ module PasswordBreachAlert
4
+ module Api
5
+ class Breachedaccount < PasswordBreachAlert::Api::Base
6
+ API_URL = 'https://haveibeenpwned.com/api/v2/breachedaccount/'.freeze
7
+
8
+ def call(account, params = nil)
9
+ query = (params || {}).reverse_merge('truncateResponse': true).to_query
10
+ endpoint = "#{API_URL}#{account}?#{query}"
11
+
12
+ with_wait do
13
+ JSON.parse(URI.parse(endpoint).open(@request_options).read)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ require 'password_breach_alert/breaches_filters/after_user_last_checked_at'
2
+ require 'password_breach_alert/breaches_filters/all_with_user'
3
+ require 'password_breach_alert/breaches_filters/new_with_user'
4
+
5
+ module PasswordBreachAlert
6
+ module BreachesFilters
7
+ end
8
+ end
@@ -0,0 +1,34 @@
1
+ require 'password_breach_alert/api/breachedaccount'
2
+
3
+ module PasswordBreachAlert
4
+ module BreachesFilters
5
+ # returns the breaches with breach_date later then checked, then updates the date on user
6
+ class AfterUserLastCheckedAt
7
+ attr_reader :api, :field
8
+
9
+ def initialize(api: PasswordBreachAlert::Api::Breachedaccount.new, field: :password_breach_alert_checked_at)
10
+ @api = api
11
+ @field = field
12
+ end
13
+
14
+ def call(user, _new_breaches, breaches)
15
+ unless user.respond_to?(field)
16
+ raise NotImplementedError.new("please add the column of method #{field} to #{user.class}")
17
+ end
18
+
19
+ unless user[field].nil? || user[field].is_a?(Time)
20
+ raise NotImplementedError.new("#{user.class}.#{field} should be a time")
21
+ end
22
+
23
+ api_breaches = api.call(user.email)
24
+ api_breaches_names = api_breaches.map { |api_breach| api_breach['Name'] }
25
+
26
+ scope = { name: api_breaches_names }
27
+ scope[:breach_date] = user[field]..Time.current if user[field]
28
+
29
+ user.update field => Time.current
30
+ breaches.where(scope)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ require 'password_breach_alert/api/breachedaccount'
2
+
3
+ module PasswordBreachAlert
4
+ module BreachesFilters
5
+ # returns the breaches where the user appears
6
+ class AllWithUser
7
+ attr_reader :api
8
+
9
+ def initialize(api: PasswordBreachAlert::Api::Breachedaccount.new)
10
+ @api = api
11
+ end
12
+
13
+ def call(user, _new_breaches, breaches)
14
+ api_breaches = api.call(user.email)
15
+ api_breaches_names = api_breaches.map { |api_breach| api_breach['Name'] }
16
+
17
+ breaches.where(name: api_breaches_names)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ require 'password_breach_alert/api/breachedaccount'
2
+
3
+ module PasswordBreachAlert
4
+ module BreachesFilters
5
+ # returns the new_breaches where the user appears
6
+ class NewWithUser
7
+ attr_reader :api
8
+
9
+ def initialize(api: PasswordBreachAlert::Api::Breachedaccount.new)
10
+ @api = api
11
+ end
12
+
13
+ def call(user, new_breaches, _breaches)
14
+ api_breaches = api.call(user.email)
15
+ api_breaches_names = api_breaches.map { |api_breach| api_breach['Name'] }
16
+
17
+ new_breaches.where(name: api_breaches_names)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ require 'password_breach_alert/breaches_policies/send_devise_notification'
2
+
3
+ module PasswordBreachAlert
4
+ module BreachesPolicies
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ module PasswordBreachAlert
2
+ module BreachesPolicies
3
+ # notify the user with an email if there are any breaches
4
+ class SendDeviseNotification
5
+ def call(user, breaches)
6
+ return if breaches.none?
7
+
8
+ user.send(:send_devise_notification, :password_breach_alert, breaches)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,45 @@
1
+ require 'password_breach_alert/api/breach'
2
+ require 'password_breach_alert/api/breachedaccount'
3
+
4
+ module PasswordBreachAlert
5
+ class Checker
6
+ attr_reader :breaches_filter, :breaches_policy
7
+
8
+ def self.default_breaches_filter
9
+ options = Devise.breaches_filter_options.call.reverse_merge(api: PasswordBreachAlert::Api::Breachedaccount.new)
10
+ Devise.breaches_filter.constantize.new(options)
11
+ end
12
+
13
+ def self.default_breaches_policy
14
+ Devise.breaches_policy.constantize.new
15
+ end
16
+
17
+ def initialize(breaches_filter: self.class.default_breaches_filter, breaches_policy: self.class.default_breaches_policy)
18
+ @breaches_filter = breaches_filter
19
+ @breaches_policy = breaches_policy
20
+ end
21
+
22
+ def call(users)
23
+ new_breaches = fetch_and_create_new_breaches
24
+ breaches = all_breaches
25
+
26
+ users.each do |user|
27
+ user_breaches = breaches_filter.call(user, new_breaches, breaches)
28
+
29
+ breaches_policy.call(user, user_breaches)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def fetch_and_create_new_breaches
36
+ api_breaches = PasswordBreachAlert::Api::Breach.new.call
37
+
38
+ PasswordBreachAlert::Models::Breach.create_new_from_api(api_breaches)
39
+ end
40
+
41
+ def all_breaches
42
+ PasswordBreachAlert::Models::Breach.all
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ module PasswordBreachAlert
2
+ module Mailer
3
+ # Deliver a password breach alert email
4
+ def password_breach_alert(record, breaches, opts = {})
5
+ @breaches = breaches
6
+ devise_mail(record, :password_breach_alert, opts)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,55 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: breaches
4
+ #
5
+ # id :integer not null, primary key
6
+ # name :string
7
+ # title :string
8
+ # domain :string
9
+ # breach_date :datetime
10
+ # added_date :datetime
11
+ # pwn_count :integer
12
+ # description :string
13
+ # logo_path :string
14
+ # data_classes :text
15
+ # created_at :datetime not null
16
+ # updated_at :datetime not null
17
+ #
18
+
19
+ module PasswordBreachAlert
20
+ # rails generate password_breach_alert
21
+ module Models
22
+ class Breach < ActiveRecord::Base # rubocop:disable ApplicationRecord
23
+ serialize :data_classes, Array
24
+
25
+ validates :name, presence: true
26
+
27
+ scope :sorted, -> { order('added_date DESC') }
28
+
29
+ def self.create_new_from_api(api_breaches)
30
+ created_ids = []
31
+ present_names = Breach.pluck(:name)
32
+
33
+ api_breaches.each do |api_breach|
34
+ next if api_breach['Name'].in?(present_names)
35
+
36
+ breach = Breach.create(
37
+ name: api_breach['Name'],
38
+ title: api_breach['Title'],
39
+ domain: api_breach['Domain'],
40
+ breach_date: api_breach['BreachDate'],
41
+ added_date: api_breach['AddedDate'],
42
+ pwn_count: api_breach['PwnCount'],
43
+ description: api_breach['Description'],
44
+ logo_path: api_breach['LogoPath'],
45
+ data_classes: api_breach['DataClasses']
46
+ )
47
+
48
+ created_ids << breach.id
49
+ end
50
+
51
+ Breach.where(id: created_ids).sorted
52
+ end
53
+ end
54
+ end
55
+ end