nopassword 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +164 -0
  3. data/Rakefile +15 -0
  4. data/app/assets/config/nopassword_manifest.js +0 -0
  5. data/app/controllers/nopassword/email_authentications_controller.rb +105 -0
  6. data/app/mailers/application_mailer.rb +5 -0
  7. data/app/mailers/nopassword/email_authentication_mailer.rb +6 -0
  8. data/app/models/nopassword/email_authentication.rb +30 -0
  9. data/app/models/nopassword/model.rb +21 -0
  10. data/app/models/nopassword/secret.rb +122 -0
  11. data/app/models/nopassword/verification.rb +50 -0
  12. data/app/models/nopassword.rb +5 -0
  13. data/app/views/layouts/nopassword/mailer.html.erb +13 -0
  14. data/app/views/layouts/nopassword/mailer.text.erb +1 -0
  15. data/app/views/nopassword/email_authentication_mailer/notification_email.html.erb +5 -0
  16. data/app/views/nopassword/email_authentication_mailer/notification_email.html.txt +3 -0
  17. data/app/views/nopassword/email_authentications/edit.html.erb +24 -0
  18. data/app/views/nopassword/email_authentications/new.html.erb +13 -0
  19. data/config/initializers/inflections.rb +3 -0
  20. data/config/routes.rb +2 -0
  21. data/db/migrate/20220210203235_create_nopassword_secrets.rb +14 -0
  22. data/db/test.sqlite3 +0 -0
  23. data/lib/generators/nopassword/install/USAGE +8 -0
  24. data/lib/generators/nopassword/install/install_generator.rb +20 -0
  25. data/lib/generators/nopassword/install/templates/controller.rb +35 -0
  26. data/lib/nopassword/encryptor.rb +27 -0
  27. data/lib/nopassword/engine.rb +7 -0
  28. data/lib/nopassword/random_code_generator.rb +43 -0
  29. data/lib/nopassword/version.rb +3 -0
  30. data/lib/nopassword.rb +12 -0
  31. data/lib/tasks/nopassword_tasks.rake +4 -0
  32. metadata +107 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8051bdffe33d49f7ced54bf1c25ea4d0c4c92f19f17aa33883e82863d2290535
4
+ data.tar.gz: 792216909882739608c8ef544ef7b13de5bcc6467cad9ef2ca62b35efef10d0e
5
+ SHA512:
6
+ metadata.gz: bb7c8f299449f03f3c085b97310a387d3647e17a114a877d77340ca3ca55d2e66f61448319081314cee10c10f09abc1cb92b7f8c6e3ac27ead1b635df6912033
7
+ data.tar.gz: 175565727c2ae0b66cd236673f0635ce4df4fa5288750dbdde41c49ce6ad13252d471d107365552b43ff81411e710178ba944da7c3203d257701d6ea45acb91c
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # NoPassword
2
+
3
+ [![Ruby](https://github.com/rocketshipio/nopassword/actions/workflows/ruby.yml/badge.svg)](https://github.com/rocketshipio/nopassword/actions/workflows/ruby.yml) [![Maintainability](https://api.codeclimate.com/v1/badges/1dab74df8828deddd5f3/maintainability)](https://codeclimate.com/github/rocketshipio/nopassword/maintainability)
4
+
5
+ NoPassword is a toolkit that makes it easy to implement temporary, secure login codes initiated from peoples' web browsers so they can login via email, SMS, CLI, QR Codes, or any other side-channel. NoPassword also comes with a pre-built "Login with Email" flow so you can start using it right away in your Rails application.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your Rails application's Gemfile by executing:
10
+
11
+ ```bash
12
+ $ bundle add nopassword
13
+ ```
14
+
15
+ Next copy over the migrations, controllers, and views that you'll customize later:
16
+
17
+ ```bash
18
+ $ bundle exec rails generate nopassword:install
19
+ ```
20
+
21
+ Then run the migrations:
22
+
23
+ ```bash
24
+ $ rake db:migrate
25
+ ```
26
+
27
+ Finally, restart the development server and head to `http://localhost:3000/email_authentication/new`.
28
+
29
+ ## Usage
30
+
31
+ Once NoPassword is installed, it can be customized directly from the controller and views that were installed. Start by openining the `app/controllers/email_authentications_controller.rb` file and you'll see code that looks like:
32
+
33
+ ```ruby
34
+ class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
35
+ # Override with your own logic to do something with the valid data. For
36
+ # example, you might setup the current user session here via:
37
+ #
38
+ # ```
39
+ # def verification_succeeded(email)
40
+ # self.current_user = User.find_or_create_by! email: email
41
+ # redirect_to dashboard_url
42
+ # end
43
+ # ```
44
+ def verification_succeeded(email)
45
+ redirect_to root_url
46
+ end
47
+
48
+ # ...
49
+ end
50
+ ```
51
+
52
+ You'll want to customize this for your application. For example, if you already have an application that uses a library like `devise`, you could setup a login-by-email flow like this:
53
+
54
+ ```ruby
55
+ class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
56
+ def verification_succeeded(email)
57
+ self.current_user = User.find_or_create_by! email: email
58
+ redirect_to root_url
59
+ end
60
+
61
+ # ...
62
+ end
63
+ ```
64
+
65
+ ## Why bother?
66
+
67
+ Passwords are a huge pain. How you ask?
68
+
69
+ 1. **People choose weak passwords** - Most people choose weak passwords that are easy to remember and type. In a stupid game of cat and mouse, the world has fought back with password complexity validations that drive people insane and make passwords really hard to remember.
70
+
71
+ 2. **People forget passwords** - When people forget their passwords they have to go through a whole reset process that sends people some sort of code via Email, SMS, or any other side-channel. Why not just authenticate this way?
72
+
73
+ 3. **Password fatigue** - People get tired of creating passwords for a website. Its a breath of fresh air when they can plugin an email address, get a code, and not have to manage yet another password.
74
+
75
+ ## Security
76
+
77
+ It's paramount to understand how your authentication systems are working so you can assess whether or not the risks they present are worth it. NoPassword is no different; it makes certain trade-offs that you need to assess for your application.
78
+
79
+ ### How it works
80
+
81
+ 1. User requests a code by entering an email address. The email address is validated based on if its well-formed or not. No other validations take place.
82
+
83
+ 2. If the email address is well-formed, Rails generates a random 6 digit number and a salt. The 6 digit number is emailed to the end-user and the salt is persisted in the browser they're using to login to the application.
84
+
85
+ 3. Rails also encrypts the email address via a `NoPassword::Secret`, The combination of the code and the salt, provided by the user, is what's needed to unlock the secret.
86
+
87
+ 4. The user receives the email, views the code, and enters it into the open browser window.
88
+ 1. If they enter the wrong code, the `remaining_attempts` field is decremented. If they exhaust all attempts, they have to request a new code and start the process over.
89
+ 2. If the end-user waits until after the `expires_at` field to enter the, they have to request a new code and start the process over.
90
+ 3. If the user enters the correct code within the alloted `remaining_attempts` and `expires_at`, the `data` from the `Secret` is decrypted and made available to your application.
91
+
92
+ Worth noting; none of the steps above require a cookie to function properly.
93
+
94
+ ### Features
95
+
96
+ NoPassword deploys the following features to mitigate brute force attacks:
97
+
98
+ #### Limit the number of times a code can be entered
99
+
100
+ NoPassword ships with `NoPassword::Secret#remaining_attempts`, which is decremented each time the user enters a code. When there's 0 remaining attempts, the secret is destroyed and the user has to request a new, uniquely generated code. By default, NoPassword gives end-users 3 attempts to try the code.
101
+
102
+ #### A randomly generated salt must also be provided with the code
103
+
104
+ The salt is hidden from the user by embedding it into the form payload that's posted back to the server with the code. This means an attack would have to somehow get this salt, in addition to the code, to successfully verify the secret.
105
+
106
+ The salt is not emailed or distributed to the end-user: it is kept in the browser they're using to authenticate. If an attacker intercepted the code from the side-channel, they would also need access to the salt.
107
+
108
+ #### Secrets expire
109
+
110
+ In addition to the salt and remaining attempts, a secret also has `NoPassword::Secret#expires_at`, which limits the amount of time a user has to guess the secret. By default, NoPassword gives end-users 5 minutes to enter the code.
111
+
112
+ #### Does not store personally identifying information ("PII")
113
+
114
+ NoPassword makes a best effort to prevent PII from being stored on the server during the authentication & authorization process. Instead the data is persisted on the client via a `data` key, and is verified on each request to ensure the client did not tamper with the orignal PII for the final authentication request. The PII is revealed after the user successfully verifies their email address.
115
+
116
+ NoPassword does not prevent other pieces of your infrastructure from logging PII, so you'll need to do your dilligence to ensure nothing is logged if your goal is to provide your users with strong privacy garauntees.
117
+
118
+ #### No session or cookies required
119
+
120
+ NoPassword persists its state on the client and in an encrypted format on the server; thus a session or cookie is not required for the verification process. This serves two purposes:
121
+
122
+ 1. **Privacy** - The initial authorization and verification process doesn't use cookies, so in theory if you run a tight ship, you won't have to display cookie banners during the authorization and verification process.
123
+
124
+ 2. **API compatibility** - The main reason NoPassword doesn't use cookies or sessions is so it can be used to authenticate via an API. This is useful for hybrid mobile app scenarios where a user may request a login code via a native UI.
125
+
126
+ ## Architecture
127
+
128
+ NoPassword takes a PORO approach to its architecture, meaning you can extend its behavior via compositions and inheritence. Because of this PORO approach, most of the configuration happens on the objects themselves via inheritance instead of a configuration file. This is a similar approach to how [authologic](https://github.com/binarylogic/authlogic) implements their authentication framework for users.
129
+
130
+ Because of this modular approach, NoPassword can be used out of the box for many use cases including:
131
+
132
+ * Login via Email
133
+ * Verify emails for logged in users
134
+ * Reset passwords for logged in users
135
+
136
+ NoPassword could be extended to work for other side-channel use cases too like login via SMS, QR code, etc.
137
+
138
+ ## Motivations
139
+
140
+ Understanding why something was created is important to understanding it better.
141
+
142
+ ### Why was NoPassword created?
143
+
144
+ The gems I evaluated all did more than I wanted them to:
145
+
146
+ 1. [passwordless](https://rubygems.org/gems/passwordless) - Same idea as this gem, but it tries to do too much by including `current_user` and all of the before_action callbacks. Ultimately this gem wasn't suitable for me because I found they way its architected makes it difficult to extend or plug into existing application code.
147
+
148
+ 2. [devise-passwordless](https://rubygems.org/gems/devise-passwordless) - This would be a good solution if you're already using devise, but like passwordless, I didn't want a gem that got into the business of `current_user`. Additionally, for new passwordless-only applications, it doesn't make sense to start with devise since it makes many assumptions about requiring a username and password.
149
+
150
+ NoPassword only worries about generating codes and creating a secure environment for end-users to validate the codes.
151
+
152
+ ### Why was it not built on devise, warden, or ominauth?
153
+
154
+ I initially thought this would make for a great OmniAuth strategy, but quickly realized OmniAuth has a goal of being agnostic to rails and ships Rack middleware. I needed something more integrated into Rails controllers and views so that I could more easily extend in various projects.
155
+
156
+ Devise already has [devise-passwordless](https://rubygems.org/gems/devise-passwordless), but it tries to do too much for my purposes by managing user authorization. I needed something that stopped short of managing user authorization.
157
+
158
+ ## Contributing
159
+
160
+ I'd like to build out a set of controllers, views, etc. for common use cases for codes, like SMS, QRCode, and email. If you'd like to contribute, lets talk about it at https://github.com/rocketshipio/nopassword/discussions/categories/ideas before you code anything and go over architectural principals, how to distribute, etc.
161
+
162
+ ## License
163
+
164
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
5
+
6
+ load "rails/tasks/engine.rake"
7
+ load "rails/tasks/statistics.rake"
8
+
9
+ begin
10
+ require 'rspec/core/rake_task'
11
+ RSpec::Core::RakeTask.new(:spec)
12
+ task :default => :spec
13
+ rescue LoadError
14
+ abort "rspec could not be loaded"
15
+ end
File without changes
@@ -0,0 +1,105 @@
1
+ class NoPassword::EmailAuthenticationsController < ApplicationController
2
+ before_action :assign_verification, only: %i[edit update destroy]
3
+ before_action :assign_email_authentication, only: :create
4
+ before_action :initialize_and_assign_email_authentication, only: :new
5
+
6
+ # These are needed to make wiring up forms a little easier for the developer.
7
+ helper_method :update_url, :create_url
8
+
9
+ def new
10
+ end
11
+
12
+ def show
13
+ redirect_to url_for(action: :new)
14
+ end
15
+
16
+ def create
17
+ if @email_authentication.valid?
18
+ deliver_authentication @email_authentication
19
+ @verification = @email_authentication.verification
20
+ render :edit
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def destroy
27
+ @verification.destroy!
28
+ end
29
+
30
+ def update
31
+ if @verification.valid?
32
+ verification_succeeded @verification.data
33
+ elsif @verification.has_expired?
34
+ verification_expired @verification
35
+ elsif @verification.has_exceeded_attempts?
36
+ verification_exceeded_attempts @verification
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ protected
43
+ # Override with your own logic to do something with the valid data. For
44
+ # example, you might setup the current user session here via:
45
+ #
46
+ # ```
47
+ # def valid(email)
48
+ # session[:user_id] = User.find_or_create_by(email: email)
49
+ # redirect_to dashboard_url
50
+ # end
51
+ # ```
52
+ def verification_succeeded(email)
53
+ redirect_to root_url
54
+ end
55
+
56
+ # Override with your own logic to deliver a code to the user.
57
+ def deliver_authentication(authentication)
58
+ NoPassword::EmailAuthenticationMailer.with(authentication: authentication).notification_email.deliver
59
+ end
60
+
61
+ # Override with logic for when verification attempts are exceeded. For
62
+ # example, you might want to tweak the flash message that's displayed
63
+ # or redirect them to a page other than the one where they'd re-verify.
64
+ def verification_exceeded_attempts(verification)
65
+ flash[:nopassword_status] = "The number of times the code can be tried has been exceeded."
66
+ redirect_to url_for(action: :new)
67
+ end
68
+
69
+ # Override with logic for when verification has expired. For
70
+ # example, you might want to tweak the flash message that's displayed
71
+ # or redirect them to a page other than the one where they'd re-verify.
72
+ def verification_expired(verification)
73
+ flash[:nopassword_status] = "The code has expired."
74
+ redirect_to url_for(action: :new)
75
+ end
76
+
77
+ def create_url
78
+ url_for(action: :create)
79
+ end
80
+
81
+ def update_url
82
+ url_for(action: :update)
83
+ end
84
+
85
+ private
86
+ def email_authentication_params
87
+ params.require(:nopassword_email_authentication).permit(:email)
88
+ end
89
+
90
+ def verification_params
91
+ params.require(:nopassword_verification).permit(:code, :salt, :data)
92
+ end
93
+
94
+ def assign_verification
95
+ @verification = NoPassword::Verification.new(verification_params)
96
+ end
97
+
98
+ def assign_email_authentication
99
+ @email_authentication = NoPassword::EmailAuthentication.new(email_authentication_params)
100
+ end
101
+
102
+ def initialize_and_assign_email_authentication
103
+ @email_authentication = NoPassword::EmailAuthentication.new
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ class ApplicationMailer < ActionMailer::Base
2
+ default from: 'from@example.com'
3
+ layout 'mailer'
4
+ end
5
+
@@ -0,0 +1,6 @@
1
+ class NoPassword::EmailAuthenticationMailer < ApplicationMailer
2
+ def notification_email
3
+ @authentication = params[:authentication]
4
+ mail(to: @authentication.email, subject: "Verification code: #{@authentication.code}")
5
+ end
6
+ end
@@ -0,0 +1,30 @@
1
+ require "uri"
2
+
3
+ class NoPassword::EmailAuthentication < NoPassword::Model
4
+ attr_accessor :email
5
+ validates :email,
6
+ presence: true,
7
+ format: { with: URI::MailTo::EMAIL_REGEXP }
8
+
9
+ def verification
10
+ # We don't want the code in the verification, otherwise
11
+ # the user will set it on the subsequent request, which
12
+ # would undermine the whole thing.
13
+ NoPassword::Verification.new(salt: salt, data: email) if valid?
14
+ end
15
+
16
+ def destroy!
17
+ secret.destroy!
18
+ end
19
+
20
+ private
21
+ delegate :code, :salt, to: :secret
22
+
23
+ def secret
24
+ @secret ||= create_secret
25
+ end
26
+
27
+ def create_secret
28
+ NoPassword::Secret.create!(data: email)
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ class NoPassword::Model
2
+ include ActiveModel::Model
3
+ include ActiveModel::Validations::Callbacks
4
+ extend ActiveModel::Naming
5
+
6
+ def initialize(*args, **kwargs)
7
+ super(*args, **kwargs)
8
+ assign_defaults
9
+ end
10
+
11
+ protected
12
+ # Subclasses would implement default assignments in the subclass.
13
+ def assign_defaults
14
+ end
15
+
16
+ # When we're dealing with t/f values, the ||= doesn't work, so we set those
17
+ # defaults up here.
18
+ def assign_default(attr, val)
19
+ self.send("#{attr}=", val) if self.send(attr).nil?
20
+ end
21
+ end
@@ -0,0 +1,122 @@
1
+ class NoPassword::Secret < ApplicationRecord
2
+ self.table_name = "nopassword_secrets"
3
+
4
+ # Initialize new models with all the stuff needed to encrypt
5
+ # and store the data.
6
+ after_initialize :assign_defaults, unless: :persisted?
7
+
8
+ validates :data_digest, presence: true
9
+ validates :code_digest, presence: true
10
+ before_validation :assign_digests, on: :create
11
+
12
+ # This is used to derive the `data_digest`, which finds the secret.
13
+ attr_accessor :salt
14
+ validates :salt, presence: true
15
+
16
+ # Maximum number of times that a verification can be attempted.
17
+ DEFAULT_REMAINING_ATTEMPTS = 3
18
+
19
+ validates :expires_at, presence: true
20
+ validate :expiration
21
+
22
+ validates :remaining_attempts,
23
+ presence: true,
24
+ numericality: {
25
+ only_integer: true,
26
+ greater_than: 0 }
27
+
28
+ # How long can the code live until it expires a new
29
+ # code verification must be created
30
+ DEFAULT_TIME_TO_LIVE = 5.minutes
31
+
32
+ attr_reader :code
33
+ validate :code_authenticity
34
+ # Ensure the code is a non-empty string. The nil will
35
+ # trigger validations and blow up the downstream Encryptor.
36
+ def code=(code)
37
+ @code = code.to_s if code.present?
38
+ end
39
+
40
+ attr_accessor :data
41
+ validates :data, presence: true
42
+ validate :data_tampering, on: :update
43
+
44
+ def has_expired?
45
+ Time.current > expires_at
46
+ end
47
+
48
+ def has_exceeded_attempts?
49
+ not remaining_attempts.positive?
50
+ end
51
+
52
+ def has_tampered_data?
53
+ self.data_digest != digest_data if persisted?
54
+ end
55
+
56
+ def has_authentic_code?
57
+ self.code_digest == digest_code
58
+ end
59
+
60
+ def decrement_remaining_attempts
61
+ decrement! :remaining_attempts
62
+ end
63
+
64
+ def self.find_by_digest(salt:, data:)
65
+ return if salt.nil?
66
+ return if data.nil?
67
+
68
+ find_by(data_digest: digest_data(salt: salt, data: data)).tap do |secret|
69
+ if secret
70
+ secret.data = data
71
+ secret.salt = salt
72
+ end
73
+ end
74
+ end
75
+
76
+ def self.digest_data(salt:, data:)
77
+ return if salt.nil?
78
+ return if data.nil?
79
+
80
+ Digest::SHA256.hexdigest(salt + data)
81
+ end
82
+
83
+ def self.digest_code(data_digest:, code:)
84
+ return if code.nil?
85
+ return if data_digest.nil?
86
+
87
+ Digest::SHA256.hexdigest(data_digest + code)
88
+ end
89
+
90
+ private
91
+ def assign_defaults
92
+ self.salt = NoPassword::Encryptor.generate_salt
93
+ self.code ||= NoPassword::RandomCodeGenerator.generate_numeric_code
94
+ self.expires_at ||= DEFAULT_TIME_TO_LIVE.from_now
95
+ self.remaining_attempts ||= DEFAULT_REMAINING_ATTEMPTS
96
+ end
97
+
98
+ def assign_digests
99
+ self.data_digest = digest_data
100
+ self.code_digest = digest_code
101
+ end
102
+
103
+ def digest_data
104
+ self.class.digest_data(salt: salt, data: data)
105
+ end
106
+
107
+ def digest_code
108
+ self.class.digest_code(data_digest: data_digest, code: code)
109
+ end
110
+
111
+ def expiration
112
+ errors.add(:expires_at, "has been exceeded") if has_expired?
113
+ end
114
+
115
+ def data_tampering
116
+ errors.add(:data, "has been tampered") if has_tampered_data?
117
+ end
118
+
119
+ def code_authenticity
120
+ errors.add(:code, "is incorrect") unless has_authentic_code?
121
+ end
122
+ end
@@ -0,0 +1,50 @@
1
+ class NoPassword::Verification < NoPassword::Model
2
+ delegate \
3
+ :has_expired?,
4
+ :has_exceeded_attempts?,
5
+ :has_authentic_code?,
6
+ :expires_at,
7
+ :remaining_attempts,
8
+ :decrement_remaining_attempts,
9
+ :persisted?,
10
+ to: :secret,
11
+ allow_nil: true
12
+
13
+ attr_accessor :salt
14
+ validates :salt, presence: true
15
+
16
+ attr_accessor :code
17
+ validates :code, presence: true
18
+ validate :code_expiration
19
+ validate :code_verification_attempts
20
+ validate :code_authenticity
21
+
22
+ attr_accessor :data
23
+ validates :data, presence: true
24
+
25
+ # This fires, even if the validation fails.
26
+ after_validation :decrement_remaining_attempts
27
+
28
+ def has_incorrect_code?
29
+ not has_authentic_code?
30
+ end
31
+
32
+ private
33
+ def code_authenticity
34
+ errors.add(:code, "is incorrect") if has_incorrect_code?
35
+ end
36
+
37
+ def code_expiration
38
+ errors.add(:code, "has expired") if has_expired?
39
+ end
40
+
41
+ def code_verification_attempts
42
+ errors.add(:code, "verification attempts have been exceeded") if has_exceeded_attempts?
43
+ end
44
+
45
+ def secret
46
+ @secret ||= NoPassword::Secret.find_by_digest(salt: salt, data: data).tap do |secret|
47
+ secret.code = code if secret
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ module NoPassword
2
+ def self.table_name_prefix
3
+ "nopassword_"
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
5
+ <style>
6
+ /* Email styles need to be inline */
7
+ </style>
8
+ </head>
9
+
10
+ <body>
11
+ <%= yield %>
12
+ </body>
13
+ </html>
@@ -0,0 +1 @@
1
+ <%= yield %>
@@ -0,0 +1,5 @@
1
+ <h1>Verify your email address</h1>
2
+
3
+ <p>Enter the code below into your open browser window to verify your email address.</p>
4
+
5
+ <code style="font-size: 3em; background-color: rgba(0.5, 0.5, 0.5, 0.05); padding: 1em 2em; text-align: center; font-family: monospace; font-weight: bold; display: inline-block; border-radius: 0.25em;"><%= @authentication.code %></code>
@@ -0,0 +1,3 @@
1
+ Enter the code below into your open browser window to verify your email address.
2
+
3
+ <%= @authentication.code %>
@@ -0,0 +1,24 @@
1
+ <h1>Verify login code</h1>
2
+
3
+ <p>Look for a 6 digit code in the inbox or spam folder.</p>
4
+
5
+ <%= form_with model: @verification, url: url_for(action: :update), data: { turbo: false }, method: :patch do |f| %>
6
+ <%= f.hidden_field :salt %>
7
+ <%= f.hidden_field :data %>
8
+ <%= f.label :code %>
9
+ <%= f.text_field :code, autofocus: true %>
10
+ <%= f.submit "Continue" %>
11
+ <% end %>
12
+
13
+ <% if @verification.errors.any? %>
14
+ <p><%= @verification.errors.full_messages.to_sentence %></p>
15
+ <% end %>
16
+
17
+ <p>
18
+ Launch
19
+ <%= link_to "Gmail", "https://gmail.com/", target: "_blank" %>
20
+ |
21
+ <%= link_to "Outlook", "https://outlook.live.com/", target: "_blank" %>
22
+ |
23
+ <%= link_to "Yahoo Mail", "https://mail.yahoo.com/", target: "_blank" %>
24
+ </p>
@@ -0,0 +1,13 @@
1
+ <h1>Get a login code</h1>
2
+
3
+ <% if message = flash[:nopassword_status] %>
4
+ <p><%= message %> Enter an email address to request a new login code and try again.</p>
5
+ <% else %>
6
+ <p>We'll email you a login code so we can securely get you to your account</p>
7
+ <% end %>
8
+
9
+ <%= form_with model: @email_authentication, url: url_for(action: :create), data: { turbo: false } do |f| %>
10
+ <%= f.label :email %>
11
+ <%= f.email_field :email, autofocus: true %>
12
+ <%= f.submit "Continue" %>
13
+ <% end %>
@@ -0,0 +1,3 @@
1
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
2
+ inflect.acronym "NoPassword"
3
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,14 @@
1
+ class CreateNoPasswordSecrets < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :nopassword_secrets do |t|
4
+ t.string :data_digest, null: false
5
+ t.string :code_digest, null: false
6
+
7
+ t.datetime :expires_at, null: false
8
+ t.integer :remaining_attempts, null: false
9
+
10
+ t.index :data_digest, unique: true
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
data/db/test.sqlite3 ADDED
Binary file
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Installs NoPassword in application by generating basic controller files
3
+
4
+ Example:
5
+ bin/rails generate install EmailAuthentication
6
+
7
+ This will create:
8
+ app/controllers/email_authentication.rb
@@ -0,0 +1,20 @@
1
+ class NoPassword::InstallGenerator < Rails::Generators::Base
2
+ source_root File.expand_path("templates", __dir__)
3
+
4
+ def copy_controller_file
5
+ copy_file "controller.rb", "app/controllers/email_authentications_controller.rb"
6
+ end
7
+
8
+ def copy_view_files
9
+ directory NoPassword.root.join("app/views/nopassword/email_authentication_mailer"), 'app/views/email_authentication_mailer'
10
+ directory NoPassword.root.join("app/views/nopassword/email_authentications"), 'app/views/email_authentications'
11
+ end
12
+
13
+ def add_nopassword_routes
14
+ route "resource :email_authentication"
15
+ end
16
+
17
+ def copy_migration_file
18
+ rake "nopassword_engine:install:migrations"
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ class EmailAuthenticationsController < NoPassword::EmailAuthenticationsController
2
+ # Override with your own logic to do something with the valid data. For
3
+ # example, you might setup the current user session here via:
4
+ #
5
+ # ```
6
+ # def verification_succeeded(email)
7
+ # self.current_user = User.find_or_create_by(email: email)
8
+ # redirect_to dashboard_url
9
+ # end
10
+ # ```
11
+ def verification_succeeded(email)
12
+ redirect_to root_url
13
+ end
14
+
15
+ # Override with logic for when verification attempts are exceeded. For
16
+ # example, you might want to tweak the flash message that's displayed
17
+ # or redirect them to a page other than the one where they'd re-verify.
18
+ def verification_exceeded_attempts(verification)
19
+ flash[:nopassword_status] = "The number of times the code can be tried has been exceeded."
20
+ redirect_to url_for(action: :new)
21
+ end
22
+
23
+ # Override with logic for when verification has expired. For
24
+ # example, you might want to tweak the flash message that's displayed
25
+ # or redirect them to a page other than the one where they'd re-verify.
26
+ def verification_expired(verification)
27
+ flash[:nopassword_status] = "The code has expired."
28
+ redirect_to url_for(action: :new)
29
+ end
30
+
31
+ # Override with your own logic to deliver a code to the user.
32
+ def deliver_authentication(authentication)
33
+ NoPassword::EmailAuthenticationMailer.with(authentication: authentication).notification_email.deliver
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ module NoPassword
2
+ # Handle encrypting and decrypting secrets.
3
+ class Encryptor
4
+ KEY_LENGTH = ActiveSupport::MessageEncryptor.key_len
5
+
6
+ def initialize(secret_key:, salt: self.class.generate_salt, key_length: KEY_LENGTH)
7
+ raise "salt can't be nil" if salt.nil?
8
+ raise "secret_key can't be nil" if secret_key.nil?
9
+
10
+ # binding.pry if secret_key.nil?
11
+ key = ActiveSupport::KeyGenerator.new(secret_key).generate_key(salt, key_length)
12
+ @crypt = ActiveSupport::MessageEncryptor.new(key)
13
+ end
14
+
15
+ def encrypt_and_sign(decrypted_data, *args, **kwargs)
16
+ @crypt.encrypt_and_sign(decrypted_data, *args, **kwargs)
17
+ end
18
+
19
+ def decrypt_and_verify(encrypted_data, *args, **kwargs)
20
+ @crypt.decrypt_and_verify(encrypted_data, *args, **kwargs)
21
+ end
22
+
23
+ def self.generate_salt(key_length: KEY_LENGTH)
24
+ SecureRandom.urlsafe_base64 key_length
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ require "nopassword"
2
+
3
+ module NoPassword
4
+ class Engine < ::Rails::Engine
5
+ config.action_mailer.preview_path = NoPassword.root.join("spec/mailers/previews")
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ class NoPassword::RandomCodeGenerator
2
+ # 6 digit random code by default.
3
+ CODE_LENGTH = 6
4
+
5
+ # Numeric to make input a tad easier with a number pad.
6
+ NUMERIC_CHARACTERS = [*'0'..'9']
7
+
8
+ # Alphanumeric, which excludes lowercase because people would typo that.
9
+ ALPHANUMERIC_CHARACTERS = [*'A'..'Z', *'0'..'9']
10
+
11
+ def initialize(length:, characters:)
12
+ @length = length
13
+ @characters = characters
14
+ end
15
+
16
+ def generate
17
+ # Why not `SecureRandom#rand`? I don't actually want a number; I want a code, with
18
+ # leading zeros, that's a string. This is the easiest way to generate that and pad it.
19
+ #
20
+ # This really should be a public API, but alas, its not, so I have to call
21
+ # it privately and pass it the characters I want this to generate for the code.
22
+ SecureRandom.send :choose, @characters, @length
23
+ end
24
+
25
+ # Convinence methods for generating codes throughout the application.
26
+ class << self
27
+ def numeric
28
+ new length: CODE_LENGTH, characters: NUMERIC_CHARACTERS
29
+ end
30
+
31
+ def generate_numeric_code
32
+ numeric.generate
33
+ end
34
+
35
+ def alphanumeric
36
+ new length: CODE_LENGTH, characters: ALPHANUMERIC_CHARACTERS
37
+ end
38
+
39
+ def generate_alphanumeric_code
40
+ alphanumeric.generate
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,3 @@
1
+ module NoPassword
2
+ VERSION = "0.1.0"
3
+ end
data/lib/nopassword.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "nopassword/version"
2
+ require "nopassword/encryptor"
3
+ require "nopassword/random_code_generator"
4
+ require "pathname"
5
+
6
+ module NoPassword
7
+ def self.root
8
+ Pathname.new(__dir__).join("..")
9
+ end
10
+ end
11
+
12
+ require "nopassword/engine"
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :nopassword do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nopassword
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brad Gessler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-03-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: NoPassword is a toolkit that makes it easy to implement temporary, secure
42
+ login codes initiated from peoples' web browsers so they can login to Rails applications
43
+ via email, SMS, CLI, QR Codes, or any other side-channel.
44
+ email:
45
+ - brad@rocketship.io
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - README.md
51
+ - Rakefile
52
+ - app/assets/config/nopassword_manifest.js
53
+ - app/controllers/nopassword/email_authentications_controller.rb
54
+ - app/mailers/application_mailer.rb
55
+ - app/mailers/nopassword/email_authentication_mailer.rb
56
+ - app/models/nopassword.rb
57
+ - app/models/nopassword/email_authentication.rb
58
+ - app/models/nopassword/model.rb
59
+ - app/models/nopassword/secret.rb
60
+ - app/models/nopassword/verification.rb
61
+ - app/views/layouts/nopassword/mailer.html.erb
62
+ - app/views/layouts/nopassword/mailer.text.erb
63
+ - app/views/nopassword/email_authentication_mailer/notification_email.html.erb
64
+ - app/views/nopassword/email_authentication_mailer/notification_email.html.txt
65
+ - app/views/nopassword/email_authentications/edit.html.erb
66
+ - app/views/nopassword/email_authentications/new.html.erb
67
+ - config/initializers/inflections.rb
68
+ - config/routes.rb
69
+ - db/migrate/20220210203235_create_nopassword_secrets.rb
70
+ - db/test.sqlite3
71
+ - lib/generators/nopassword/install/USAGE
72
+ - lib/generators/nopassword/install/install_generator.rb
73
+ - lib/generators/nopassword/install/templates/controller.rb
74
+ - lib/nopassword.rb
75
+ - lib/nopassword/encryptor.rb
76
+ - lib/nopassword/engine.rb
77
+ - lib/nopassword/random_code_generator.rb
78
+ - lib/nopassword/version.rb
79
+ - lib/tasks/nopassword_tasks.rake
80
+ homepage: https://github.com/rocketshipio/nopassword
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ allowed_push_host: https://rubygems.org
85
+ homepage_uri: https://github.com/rocketshipio/nopassword
86
+ source_code_uri: https://github.com/rocketshipio/nopassword
87
+ changelog_uri: https://github.com/rocketshipio/nopassword/releases
88
+ post_install_message:
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 3.2.3
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Passwordless login to Rails applications via email
107
+ test_files: []