passwordless 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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.