passwordless 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb17c0cc08c9dd0062b47d67a470adeee2d0e7dc618e48967180eea61796f47a
4
- data.tar.gz: 870dd7a03e131344f20bdee24ab614e1ef66ff3fe8faf9d4bd05bb08adce032d
3
+ metadata.gz: 5f6c0006a3a1aa712922ef4c48b3afc4b5f1b722d9f37f624b367cb2e66ea895
4
+ data.tar.gz: 2c06ad86a47e11757a978fd879b7ab23e888589ecc1e734740886f148d1abb5a
5
5
  SHA512:
6
- metadata.gz: e43144bae67ad300ae753fe131ec6b09fcf997c975e2fcb9a819d457fb0178797d03b4e7278c3c92c4cc18664c54c5231de428be7f6fc63c05a878516e628f7a
7
- data.tar.gz: 5aeca84a612458d6e2dce0b0e9335d80a9f4e4af730540a148c3ef0ed6056417a33b444bfc17c19adf06744a7a2fa38dccb82189206bf2c86354352540b41e7e
6
+ metadata.gz: 1cecfce2bcc7a6427cd037123aa4733c034126f2f55d47445d8439144db325aa00796cadfa9da40daa429a54073a7fc5c4e61d6f088fd2c8feb8fc653cd593fb
7
+ data.tar.gz: 673f6c504efd94c5f01f2bd073c427012e91d4c6bacf1a113cdc851b1bcb083183a7634ded733ac22f307e9ef4ccf4fbd2a30919dddce5ce0683e3c61b04380e
data/README.md CHANGED
@@ -16,6 +16,7 @@ Add authentication to your Rails app without all the icky-ness of passwords.
16
16
  * [Usage](#usage)
17
17
  * [Getting the current user, restricting access, the usual](#getting-the-current-user-restricting-access-the-usual)
18
18
  * [Providing your own templates](#providing-your-own-templates)
19
+ * [Claming tokens](#claiming-tokens)
19
20
  * [Overrides](#overrides)
20
21
  * [Registering new users](#registering-new-users)
21
22
  * [Generating tokens](#generating-tokens)
@@ -43,7 +44,7 @@ $ bin/rails passwordless:install:migrations
43
44
 
44
45
  ## Usage
45
46
 
46
- Passwordless creates a single model called `Passwordless::Session`. It doesn't come with its own `User` model, it expects you to create one, eg.:
47
+ Passwordless creates a single model called `Passwordless::Session`. It doesn't come with its own `User` model, it expects you to create one:
47
48
 
48
49
  ```
49
50
  $ bin/rails generate model User email
@@ -71,7 +72,7 @@ end
71
72
 
72
73
  ### Getting the current user, restricting access, the usual
73
74
 
74
- Passwordless doesn't give you `current_user` automatically -- it's dead easy to add it though:
75
+ Passwordless doesn't give you `current_user` automatically. Here's how you could add it:
75
76
 
76
77
  ```ruby
77
78
  class ApplicationController < ActionController::Base
@@ -84,7 +85,7 @@ class ApplicationController < ActionController::Base
84
85
  private
85
86
 
86
87
  def current_user
87
- @current_user ||= authenticate_by_cookie(User)
88
+ @current_user ||= authenticate_by_session(User)
88
89
  end
89
90
 
90
91
  def require_user!
@@ -121,36 +122,20 @@ app/views/passwordless/mailer/magic_link.text.erb
121
122
 
122
123
  See [the bundled views](https://github.com/mikker/passwordless/tree/master/app/views/passwordless).
123
124
 
124
- ### Overrides
125
-
126
- By default `passwordless` uses the `passwordless_with` column you specify in the model to case insensitively fetch the resource during authentication. You can override this and provide your own customer fetcher by defining a class method `fetch_resource_for_passwordless` in your passwordless model. The method will be supplied with the downcased email and should return an `ActiveRecord` instance of the model.
127
-
128
- Example time:
129
-
130
- Let's say we would like to fetch the record and if it doesn't exist, create automatically.
131
-
132
- ```ruby
133
- class User < ApplicationRecord
134
- def self.fetch_resource_for_passwordless(email)
135
- find_or_create_by(email: email)
136
- end
137
- end
138
- ```
139
-
140
125
  ### Registering new users
141
126
 
142
- Because your `User` record is like any other record, you create one like you normally would. Passwordless provides a helper method you can use to sign in the created user after it is saved like so:
127
+ Because your `User` record is like any other record, you create one like you normally would. Passwordless provides a helper method to sign in the created user after it is saved like so:
143
128
 
144
129
  ```ruby
145
130
  class UsersController < ApplicationController
146
131
  include Passwordless::ControllerHelpers # <-- This!
147
- # (unless you already have it in your ApplicationController)
132
+ # (unless you already have it in your ApplicationController)
148
133
 
149
134
  def create
150
135
  @user = User.new user_params
151
136
 
152
137
  if @user.save
153
- sign_in @user # <-- And this!
138
+ sign_in @user # <-- This!
154
139
  redirect_to @user, flash: {notice: 'Welcome!'}
155
140
  else
156
141
  render :new
@@ -163,7 +148,7 @@ end
163
148
 
164
149
  ### Generating tokens
165
150
 
166
- By default Passwordless generates tokens using Rails' `SecureRandom.urlsafe_base64` but you can change that by setting `Passwordless.token_generator` to something else that responds to `call(session)` eg.:
151
+ By default Passwordless generates tokens using `SecureRandom.urlsafe_base64` but you can change that by setting `Passwordless.token_generator` to something else that responds to `call(session)` eg.:
167
152
 
168
153
  ```ruby
169
154
  Passwordless.token_generator = -> (session) {
@@ -241,7 +226,7 @@ By default, magic link will send by email. You can customize this method. For ex
241
226
  config/initializers/passwordless.rb
242
227
 
243
228
  ```
244
- Passwordless.after_session_save = lambda do |session|
229
+ Passwordless.after_session_save = lambda do |session, request|
245
230
  # Default behavior is
246
231
  # Mailer.magic_link(session).deliver_now
247
232
 
@@ -252,6 +237,55 @@ end
252
237
 
253
238
  You can access user model through authenticatable.
254
239
 
240
+ ### Claiming tokens
241
+
242
+ Opt-in for marking tokens as `claimed` so they can only be used once.
243
+
244
+ config/initializers/passwordless.rb
245
+
246
+ ```ruby
247
+ # Default is `false`
248
+ Passwordless.restrict_token_reuse = true
249
+ ```
250
+
251
+ #### Upgrading an existing Rails app
252
+
253
+ The simplest way to update your sessions table is with a single migration:
254
+
255
+ <details>
256
+ <summary>Example migration</summary>
257
+
258
+ ```bash
259
+ bin/rails generate migration add_claimed_at_to_passwordless_sessions
260
+ ```
261
+
262
+ ```ruby
263
+ class AddClaimedAtToPasswordlessSessions < ActiveRecord::Migration[5.2]
264
+ def change
265
+ add_column :passwordless_sessions, :claimed_at, :datetime
266
+ end
267
+ end
268
+
269
+ ```
270
+ </details>
271
+
272
+ ### Overrides
273
+
274
+ By default `passwordless` uses the `passwordless_with` column to _case insensitively_ fetch the resource.
275
+
276
+ You can override this and provide your own customer fetcher by defining a class method `fetch_resource_for_passwordless` in your passwordless model. The method will be called with the downcased email and should return an `ActiveRecord` instance of the model.
277
+
278
+ Example time:
279
+
280
+ Let's say we would like to fetch the record and if it doesn't exist, create automatically.
281
+
282
+ ```ruby
283
+ class User < ApplicationRecord
284
+ def self.fetch_resource_for_passwordless(email)
285
+ find_or_create_by(email: email)
286
+ end
287
+ end
288
+ ```
255
289
 
256
290
  ### E-mail security
257
291
 
@@ -261,6 +295,10 @@ But be aware that when everyone authenticates via emails you send, the way you s
261
295
 
262
296
  Ideally you should set up your email provider to not log these mails. And be sure to turn on 2-factor auth if your provider supports it.
263
297
 
298
+ # Alternatives
299
+
300
+ - [OTP JWT](https://github.com/stas/otp-jwt) -- Passwordless JSON Web Tokens
301
+
264
302
  # License
265
303
 
266
304
  MIT
@@ -5,9 +5,6 @@ require "bcrypt"
5
5
  module Passwordless
6
6
  # Controller for managing Passwordless sessions
7
7
  class SessionsController < ApplicationController
8
- # Raise this exception when a session is expired.
9
- class SessionTimedOutError < StandardError; end
10
-
11
8
  include ControllerHelpers
12
9
 
13
10
  # get '/sign_in'
@@ -26,7 +23,11 @@ module Passwordless
26
23
  session = build_passwordless_session(find_authenticatable)
27
24
 
28
25
  if session.save
29
- Passwordless.after_session_save.call(session)
26
+ if Passwordless.after_session_save.arity == 2
27
+ Passwordless.after_session_save.call(session, request)
28
+ else
29
+ Passwordless.after_session_save.call(session)
30
+ end
30
31
  end
31
32
 
32
33
  render
@@ -42,20 +43,17 @@ module Passwordless
42
43
  # Make it "slow" on purpose to make brute-force attacks more of a hassle
43
44
  BCrypt::Password.create(params[:token])
44
45
 
45
- session = find_session
46
- raise SessionTimedOutError if session.timed_out?
47
-
48
- sign_in session.authenticatable
46
+ destination =
47
+ Passwordless.redirect_back_after_sign_in &&
48
+ reset_passwordless_redirect_location!(User)
49
49
 
50
- redirect_enabled = Passwordless.redirect_back_after_sign_in
51
- destination = reset_passwordless_redirect_location!(User)
50
+ sign_in passwordless_session
52
51
 
53
- if redirect_enabled && destination
54
- redirect_to destination
55
- else
56
- redirect_to main_app.root_path
57
- end
58
- rescue SessionTimedOutError
52
+ redirect_to destination || main_app.root_path
53
+ rescue Errors::TokenAlreadyClaimedError
54
+ flash[:error] = I18n.t(".passwordless.sessions.create.token_claimed")
55
+ redirect_to main_app.root_path
56
+ rescue Errors::SessionTimedOutError
59
57
  flash[:error] = I18n.t(".passwordless.sessions.create.session_expired")
60
58
  redirect_to main_app.root_path
61
59
  end
@@ -98,8 +96,8 @@ module Passwordless
98
96
  end
99
97
  end
100
98
 
101
- def find_session
102
- Session.find_by!(
99
+ def passwordless_session
100
+ @passwordless_session ||= Session.find_by!(
103
101
  authenticatable_type: authenticatable_classname,
104
102
  token: params[:token]
105
103
  )
@@ -18,10 +18,17 @@ module Passwordless
18
18
 
19
19
  before_validation :set_defaults
20
20
 
21
- scope :valid, lambda {
21
+ scope :available, lambda {
22
22
  where("timeout_at > ?", Time.current)
23
23
  }
24
24
 
25
+ def self.valid
26
+ available
27
+ end
28
+ class << self
29
+ deprecate :valid, deprecator: SessionValidDeprecation
30
+ end
31
+
25
32
  def expired?
26
33
  expires_at <= Time.current
27
34
  end
@@ -30,6 +37,19 @@ module Passwordless
30
37
  timeout_at <= Time.current
31
38
  end
32
39
 
40
+ def claim!
41
+ raise Errors::TokenAlreadyClaimedError if claimed?
42
+ touch(:claimed_at)
43
+ end
44
+
45
+ def claimed?
46
+ !!claimed_at
47
+ end
48
+
49
+ def available?
50
+ !timed_out? && !expired?
51
+ end
52
+
33
53
  private
34
54
 
35
55
  def set_defaults
@@ -5,8 +5,9 @@ en:
5
5
  create:
6
6
  session_expired: 'Your session has expired, please sign in again.'
7
7
  email_sent_if_record_found: "If we found you in the system, we've sent you an email."
8
+ token_claimed: "This link has already been used, try requesting the link again"
8
9
  new:
9
10
  submit: 'Send magic link'
10
11
  mailer:
11
- subject: "Your magic link ✨'"
12
+ subject: "Your magic link ✨"
12
13
  magic_link: "Here's your link: %{link}"
@@ -10,6 +10,7 @@ class CreatePasswordlessSessions < ActiveRecord::Migration[5.1]
10
10
  )
11
11
  t.datetime :timeout_at, null: false
12
12
  t.datetime :expires_at, null: false
13
+ t.datetime :claimed_at
13
14
  t.text :user_agent, null: false
14
15
  t.string :remote_addr, null: false
15
16
  t.string :token, null: false
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
4
+ require "passwordless/errors"
3
5
  require "passwordless/engine"
4
6
  require "passwordless/url_safe_base_64_generator"
5
7
 
@@ -7,11 +9,17 @@ require "passwordless/url_safe_base_64_generator"
7
9
  module Passwordless
8
10
  mattr_accessor(:default_from_address) { "CHANGE_ME@example.com" }
9
11
  mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
12
+ mattr_accessor(:restrict_token_reuse) { false }
10
13
  mattr_accessor(:redirect_back_after_sign_in) { true }
11
14
  mattr_accessor(:mounted_as) { :configured_when_mounting_passwordless }
12
15
 
13
16
  mattr_accessor(:expires_at) { lambda { 1.year.from_now } }
14
17
  mattr_accessor(:timeout_at) { lambda { 1.hour.from_now } }
15
18
 
16
- mattr_accessor(:after_session_save) { lambda { |session| Mailer.magic_link(session).deliver_now } }
19
+ mattr_accessor(:after_session_save) do
20
+ lambda { |session, _request| Mailer.magic_link(session).deliver_now }
21
+ end
22
+
23
+ CookieDeprecation = ActiveSupport::Deprecation.new("0.9", "passwordless")
24
+ SessionValidDeprecation = ActiveSupport::Deprecation.new("0.9", "passwordless")
17
25
  end
@@ -3,6 +3,12 @@
3
3
  module Passwordless
4
4
  # Helpers to work with Passwordless sessions from controllers
5
5
  module ControllerHelpers
6
+ # Returns the {Passwordless::Session} (if set) from the session.
7
+ # @return [Session, nil]
8
+ def find_passwordless_session_for(authenticatable_class)
9
+ Passwordless::Session.find_by(id: session[session_key(authenticatable_class)])
10
+ end
11
+
6
12
  # Build a new Passwordless::Session from an _authenticatable_ record.
7
13
  # Set's `user_agent` and `remote_addr` from Rails' `request`.
8
14
  # @param authenticatable [ActiveRecord::Base] Instance of an
@@ -17,6 +23,7 @@ module Passwordless
17
23
  end
18
24
  end
19
25
 
26
+ # @deprecated Use {ControllerHelpers#authenticate_by_session}
20
27
  # Authenticate a record using cookies. Looks for a cookie corresponding to
21
28
  # the _authenticatable_class_. If found try to find it in the database.
22
29
  # @param authenticatable_class [ActiveRecord::Base] any Model connected to
@@ -27,54 +34,119 @@ module Passwordless
27
34
  def authenticate_by_cookie(authenticatable_class)
28
35
  key = cookie_name(authenticatable_class)
29
36
  authenticatable_id = cookies.encrypted[key]
30
- return unless authenticatable_id
31
37
 
32
- authenticatable_class.find_by(id: authenticatable_id)
38
+ return authenticatable_class.find_by(id: authenticatable_id) if authenticatable_id
39
+
40
+ authenticate_by_session(authenticatable_class)
41
+ end
42
+ deprecate :authenticate_by_cookie, deprecator: CookieDeprecation
43
+
44
+ def upgrade_passwordless_cookie(authenticatable_class)
45
+ key = cookie_name(authenticatable_class)
46
+
47
+ return unless authenticatable_id = cookies.encrypted[key]
48
+ cookies.encrypted.permanent[key] = {value: nil}
49
+ cookies.delete(key)
50
+
51
+ return unless record = authenticatable_class.find_by(id: authenticatable_id)
52
+ new_session = build_passwordless_session(record).tap { |s| s.save! }
53
+
54
+ sign_in new_session
55
+
56
+ new_session.authenticatable
57
+ end
58
+
59
+ # Authenticate a record using the session. Looks for a session key corresponding to
60
+ # the _authenticatable_class_. If found try to find it in the database.
61
+ # @param authenticatable_class [ActiveRecord::Base] any Model connected to
62
+ # passwordless. (e.g - _User_ or _Admin_).
63
+ # @return [ActiveRecord::Base|nil] an instance of Model found by id stored
64
+ # in cookies.encrypted or nil if nothing is found.
65
+ # @see ModelHelpers#passwordless_with
66
+ def authenticate_by_session(authenticatable_class)
67
+ return unless find_passwordless_session_for(authenticatable_class)&.available?
68
+ find_passwordless_session_for(authenticatable_class).authenticatable
33
69
  end
34
70
 
35
- # Signs in user by assigning their id to a permanent cookie.
36
- # @param authenticatable [ActiveRecord::Base] Instance of Model to sign in
37
- # (e.g - @user when @user = User.find(id: some_id)).
71
+ # Signs in session
72
+ # @param authenticatable [Passwordless::Session] Instance of {Passwordless::Session}
73
+ # to sign in
38
74
  # @return [ActiveRecord::Base] the record that is passed in.
39
- def sign_in(authenticatable)
40
- key = cookie_name(authenticatable.class)
41
- cookies.encrypted.permanent[key] = {value: authenticatable.id}
42
- authenticatable
75
+ def sign_in(record)
76
+ passwordless_session =
77
+ if record.is_a?(Passwordless::Session)
78
+ record
79
+ else
80
+ warn "Passwordless::ControllerHelpers#sign_in with authenticatable " \
81
+ "(`#{record.class}') is deprecated. Falling back to creating a " \
82
+ "new Passwordless::Session"
83
+ build_passwordless_session(record).tap { |s| s.save! }
84
+ end
85
+
86
+ passwordless_session.claim! if Passwordless.restrict_token_reuse
87
+
88
+ raise Passwordless::Errors::SessionTimedOutError if passwordless_session.timed_out?
89
+
90
+ key = session_key(passwordless_session.authenticatable_type)
91
+ session[key] = passwordless_session.id
92
+
93
+ if record.is_a?(Passwordless::Session)
94
+ passwordless_session
95
+ else
96
+ passwordless_session.authenticatable
97
+ end
43
98
  end
44
99
 
45
- # Signs out user by deleting their encrypted cookie.
46
- # @param (see #authenticate_by_cookie)
100
+ # Signs out user by deleting the session key.
101
+ # @param (see #authenticate_by_session)
47
102
  # @return [boolean] Always true
48
103
  def sign_out(authenticatable_class)
104
+ # Deprecated - cookies
49
105
  key = cookie_name(authenticatable_class)
50
106
  cookies.encrypted.permanent[key] = {value: nil}
51
107
  cookies.delete(key)
108
+ # /deprecated
109
+
110
+ reset_session
52
111
  true
53
112
  end
54
113
 
55
114
  # Saves request.original_url as the redirect location for a
56
115
  # passwordless Model.
57
- # @param (see #authenticate_by_cookie)
116
+ # @param (see #authenticate_by_session)
58
117
  # @return [String] the redirect url that was just saved.
59
118
  def save_passwordless_redirect_location!(authenticatable_class)
60
- session[session_key(authenticatable_class)] = request.original_url
119
+ session[redirect_session_key(authenticatable_class)] = request.original_url
61
120
  end
62
121
 
63
122
  # Resets the redirect_location to root_path by deleting the redirect_url
64
123
  # from session.
65
- # @param (see #authenticate_by_cookie)
124
+ # @param (see #authenticate_by_session)
66
125
  # @return [String, nil] the redirect url that was just deleted,
67
126
  # or nil if no url found for given Model.
68
127
  def reset_passwordless_redirect_location!(authenticatable_class)
69
- session.delete session_key(authenticatable_class)
128
+ session.delete(redirect_session_key(authenticatable_class))
129
+ end
130
+
131
+ def session_key(authenticatable_class)
132
+ :"passwordless_session_id--#{authenticatable_class_parameterized(authenticatable_class)}"
70
133
  end
71
134
 
72
135
  private
73
136
 
74
- def session_key(authenticatable_class)
75
- :"passwordless_prev_location--#{authenticatable_class.base_class}"
137
+ def authenticatable_class_parameterized(authenticatable_class)
138
+ if authenticatable_class.is_a?(String)
139
+ authenticatable_class = authenticatable_class.constantize
140
+ end
141
+
142
+ authenticatable_class.base_class.to_s.parameterize
143
+ end
144
+
145
+ def redirect_session_key(authenticatable_class)
146
+ :"passwordless_prev_location--#{authenticatable_class_parameterized(authenticatable_class)}"
76
147
  end
77
148
 
149
+ # Deprecated
78
150
  def cookie_name(authenticatable_class)
79
151
  :"#{authenticatable_class.base_class.to_s.underscore}_id"
80
152
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passwordless
4
+ module Errors
5
+ # Raise this exception when a session is expired.
6
+ class SessionTimedOutError < StandardError; end
7
+
8
+ # Raise this exception when the token has been previously claimed
9
+ class TokenAlreadyClaimedError < StandardError; end
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Passwordless
4
- VERSION = '0.7.0' # :nodoc:
4
+ VERSION = "0.8.0" # :nodoc:
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: passwordless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikkel Malmberg
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-03-06 00:00:00.000000000 Z
11
+ date: 2019-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 1.3.6
47
+ version: 1.4.1
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 1.3.6
54
+ version: 1.4.1
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: yard
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -104,6 +104,7 @@ files:
104
104
  - lib/passwordless.rb
105
105
  - lib/passwordless/controller_helpers.rb
106
106
  - lib/passwordless/engine.rb
107
+ - lib/passwordless/errors.rb
107
108
  - lib/passwordless/model_helpers.rb
108
109
  - lib/passwordless/router_helpers.rb
109
110
  - lib/passwordless/url_safe_base_64_generator.rb
@@ -127,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
127
128
  - !ruby/object:Gem::Version
128
129
  version: '0'
129
130
  requirements: []
130
- rubygems_version: 3.0.1
131
+ rubygems_version: 3.0.4
131
132
  signing_key:
132
133
  specification_version: 4
133
134
  summary: Add authentication to your app without all the ickyness of passwords.