avocado 0.4.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +8 -39
  4. data/app/controllers/avocado/affirmations_controller.rb +50 -0
  5. data/app/controllers/avocado/base_controller.rb +22 -0
  6. data/app/controllers/avocado/emails_controller.rb +48 -0
  7. data/app/controllers/avocado/events_controller.rb +9 -0
  8. data/app/controllers/avocado/passwords_controller.rb +34 -0
  9. data/app/controllers/avocado/recoveries_controller.rb +9 -13
  10. data/app/controllers/avocado/registrations_controller.rb +4 -4
  11. data/app/controllers/avocado/sessions_controller.rb +4 -4
  12. data/app/controllers/avocado/verifications_controller.rb +5 -2
  13. data/app/views/avocado/affirmations/edit.html.erb +7 -0
  14. data/app/views/avocado/affirmations/new.html.erb +14 -0
  15. data/app/views/avocado/emails/edit.html.erb +20 -0
  16. data/app/views/avocado/events/_event.html.erb +6 -0
  17. data/app/views/avocado/events/index.html.erb +17 -0
  18. data/app/views/avocado/mailer/email_affirmation.text.erb +3 -1
  19. data/app/views/avocado/mailer/email_verification.text.erb +2 -2
  20. data/app/views/avocado/mailer/password_reset.text.erb +1 -1
  21. data/app/views/avocado/passwords/edit.html.erb +16 -0
  22. data/app/views/avocado/recoveries/edit.html.erb +2 -2
  23. data/app/views/avocado/recoveries/new.html.erb +3 -3
  24. data/app/views/avocado/registrations/new.html.erb +1 -1
  25. data/app/views/avocado/sessions/new.html.erb +1 -1
  26. data/app/views/avocado/verifications/edit.html.erb +7 -0
  27. data/config/routes/avocado.rb +10 -0
  28. data/config/routes//360/237/245/221.rb +1 -0
  29. data/docs/USAGE.md +155 -0
  30. data/lib/avocado/authentication.rb +2 -2
  31. data/lib/avocado/current.rb +10 -8
  32. data/lib/avocado/engine.rb +5 -0
  33. data/lib/avocado/event.rb +32 -0
  34. data/lib/avocado/session.rb +9 -0
  35. data/lib/avocado/session_callbacks.rb +34 -0
  36. data/lib/avocado/user.rb +10 -3
  37. data/lib/avocado/user_callbacks.rb +45 -0
  38. data/lib/avocado/user_validations.rb +22 -0
  39. data/lib/avocado/version.rb +1 -1
  40. data/lib/avocado.rb +5 -2
  41. data/lib/generators/avocado/migrations/migrations_generator.rb +36 -0
  42. data/lib/generators/avocado/migrations/templates/create_events.rb.tt +12 -0
  43. data/lib/generators/avocado/migrations/templates/create_sessions.rb.tt +12 -0
  44. data/lib/generators/avocado/migrations/templates/create_users.rb.tt +11 -0
  45. metadata +28 -37
  46. data/config/routes.rb +0 -8
  47. data/lib/avocado/user_email.rb +0 -13
  48. data/lib/avocado/user_password.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e265345c4e35e1dd2caed0c61da1298f12767aac67bf9e87b92433d47866375b
4
- data.tar.gz: 1253d00e053907caa9d78cd7f8667a695aae55853fbb6db94b45bbcd1845da10
3
+ metadata.gz: 393630ca933c51e34b00e2fe86bbcfb5caa4ea7764fc34882c21ef4f604bb938
4
+ data.tar.gz: bfc5362c53ecee86c6369a1318c68fddf2bbd7e457287cb5b9c6b57970ded8aa
5
5
  SHA512:
6
- metadata.gz: 2fb300cf06c7c67fc9ecfea196bd23b770482f2b765d9438fb2ee24f193942565db6d470a75bce99bb1b016b94f355ab9e4b6f6a5a19bb0277d79c7bb06c247d
7
- data.tar.gz: 9437dfe9000245f75089c3705a3bf4527705ceccab38a6760381d57dd13fda74881eb14db25b8ca33f26aee67fa6fd30ce42632a24541bcb1c672e6ea9eb80b5
6
+ metadata.gz: efbe7bfe5b298b207e65e7958e3167be69d6612bd50e8a4a880483ec1a1d9e2c30d45fdae86e0516d34bb00442c0cd16416ad77a00a50ac31c707295d1538709
7
+ data.tar.gz: b96ba925c445b78c92c560a01c395697dab736363a847c766e310879ded4ad58b97b7dae052a73ed1a267f7315bedd171d9f0097978760cb3de6a1e9b3c02969
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2023-07-25
4
+
5
+ - Change affirmations and verifications to require user action
6
+ - Use session token instead of id for signed cookie value
7
+ - Add migration generators
8
+
9
+ ## [0.5.0] - 2023-07-21
10
+
11
+ - Add controller for "passwordless" email-link sign-in
12
+ - Add event class to log user auth events
13
+ - Add user-facing email and password edit pages
14
+ - Add various event logging callbacks
15
+ - Sign out all non current sessions when password changes
16
+
3
17
  ## [0.4.0] - 2023-07-19
4
18
 
5
19
  - Convert the `Avocado::Mailer` module into a class
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Avocado
1
+ # 🥑
2
2
 
3
- A collection of authentication tools for use in [Rails] 7.1+ applications.
3
+ Authentication library for [Rails] 7.1+ applications.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,45 +10,13 @@ Add to the application's Gemfile by executing:
10
10
 
11
11
  ## Usage
12
12
 
13
- If you are nervous about using Rails features directly, preferring to consume
14
- such features via a packaged gem, you can include some Avocado modules into your
15
- application to get authentication functionality.
16
-
17
- As a prerequisite, you should have a database schema with columns that match the
18
- users and sessions tables from [the demo app schema]. It's ok to have more
19
- columns, but you need at least what is shown there.
20
-
21
- With that set, include the modules into your classes:
22
-
23
- ```ruby
24
- class User < ApplicationRecord
25
- include Avocado::User
26
- end
27
-
28
- class Session < ApplicationRecord
29
- include Avocado::Session
30
- end
31
-
32
- class ApplicationController < ActionController::Base
33
- include Avocado::Authentication
34
- end
35
- ```
36
-
37
- This will enable a few things:
38
-
39
- - Models will get validations, associations, and normalizations
40
- - Rails built-in `has_secure_password` is called within `User`
41
- - A mailer with signed token generators is created
42
- - Controllers and Routes for sign up, sign in, password reset, email
43
- verification, etc
44
-
45
- The `spec/internal` app within this repo has some example usage.
13
+ Read the [documentation] for more details or the [wiki] for background.
46
14
 
47
15
  ## Development
48
16
 
49
- After checking out the repo, run `bin/setup` to install dependencies. Then, run
50
- `rake spec` to run the tests. You can also run `bin/console` for an interactive
51
- prompt that will allow you to experiment.
17
+ After checking out the repo, run `bin/setup` to install dependencies. Use
18
+ `bin/rspec` to run the full spec suite and `bin/standardrb` to run the linter.
19
+ Running `bin/rake` will run specs & linter.
52
20
 
53
21
  ## Contributing
54
22
 
@@ -58,7 +26,8 @@ Bug reports and pull requests are welcome on [GitHub].
58
26
 
59
27
  The gem is available as open source under the terms of the [MIT License].
60
28
 
29
+ [documentation]: https://github.com/tcuwp/avocado/blob/main/docs/USAGE.md
61
30
  [GitHub]: https://github.com/tcuwp/avocado
62
31
  [MIT License]: https://opensource.org/licenses/MIT
63
32
  [Rails]: https://github.com/rails/rails
64
- [the demo app schema]: https://github.com/tcuwp/avocado/blob/main/spec/internal/db/schema.rb
33
+ [wiki]: https://github.com/tcuwp/avocado/wiki
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class AffirmationsController < BaseController
5
+ skip_before_action :authenticate
6
+
7
+ before_action :set_user, only: %i[edit update]
8
+ before_action :verify_user, only: :create
9
+
10
+ def new
11
+ end
12
+
13
+ def create
14
+ send_affirmation_email
15
+ redirect_to new_session_path, notice: "Check your email for sign in instructions"
16
+ end
17
+
18
+ def edit
19
+ end
20
+
21
+ def update
22
+ sign_in(@user)
23
+ redirect_to(root_path, notice: "Signed in successfully")
24
+ end
25
+
26
+ private
27
+
28
+ def set_user
29
+ @user = user_from_signed_affirmation_token
30
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
31
+ redirect_to new_affirmation_path, alert: "That sign in link is invalid"
32
+ end
33
+
34
+ def user_from_signed_affirmation_token
35
+ ::User.find_by_token_for!(:email_affirmation, params[:id])
36
+ end
37
+
38
+ def verify_user
39
+ unless requested_verified_user
40
+ redirect_to new_affirmation_path, alert: "You can't sign in until you verify your email"
41
+ end
42
+ end
43
+
44
+ def send_affirmation_email
45
+ mailer_for(requested_verified_user)
46
+ .email_affirmation
47
+ .deliver_later
48
+ end
49
+ end
50
+ end
@@ -2,8 +2,30 @@
2
2
 
3
3
  module Avocado
4
4
  class BaseController < ApplicationController
5
+ FINDER_PARAMETERS = %i[email]
6
+
5
7
  private
6
8
 
9
+ def verify_password_challenge
10
+ unless current_user.authenticate(params_password_challenge)
11
+ redirect_back alert: "Password challenge failed.", fallback_location: root_path
12
+ end
13
+ end
14
+
15
+ def params_password_challenge
16
+ params.dig(:user, :password_challenge)
17
+ end
18
+
19
+ def requested_verified_user
20
+ ::User.verified.find_by(email: finder_parameters[:email])
21
+ end
22
+
23
+ def finder_parameters
24
+ params
25
+ .require(:user)
26
+ .permit(FINDER_PARAMETERS)
27
+ end
28
+
7
29
  def mailer_for(user)
8
30
  Avocado::Mailer.with(user: user)
9
31
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class EmailsController < BaseController
5
+ UPDATE_PARAMETERS = %i[email]
6
+
7
+ before_action :set_user
8
+ before_action :verify_password_challenge, only: :update
9
+
10
+ def edit
11
+ end
12
+
13
+ def update
14
+ if @user.update(update_parameters)
15
+ process_email_update
16
+ else
17
+ render :edit, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def set_user
24
+ @user = current_user
25
+ end
26
+
27
+ def update_parameters
28
+ params
29
+ .require(:user)
30
+ .permit(UPDATE_PARAMETERS)
31
+ end
32
+
33
+ def process_email_update
34
+ if @user.email_previously_changed?
35
+ resend_email_verification
36
+ redirect_to root_path, notice: "Your email has been changed"
37
+ else
38
+ redirect_to root_path
39
+ end
40
+ end
41
+
42
+ def resend_email_verification
43
+ mailer_for(@user)
44
+ .email_verification
45
+ .deliver_later
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class EventsController < BaseController
5
+ def index
6
+ @events = current_user.events.newest_first
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class PasswordsController < BaseController
5
+ UPDATE_PARAMETERS = %i[password password_confirmation password_challenge]
6
+
7
+ before_action :set_user
8
+ before_action :verify_password_challenge, only: :update
9
+
10
+ def edit
11
+ end
12
+
13
+ def update
14
+ if @user.update(update_parameters)
15
+ redirect_to root_path, notice: "Your password has been changed"
16
+ else
17
+ render :edit, status: :unprocessable_entity
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def set_user
24
+ @user = current_user
25
+ end
26
+
27
+ def update_parameters
28
+ params
29
+ .require(:user)
30
+ .permit(UPDATE_PARAMETERS)
31
+ .with_defaults(password_challenge: "")
32
+ end
33
+ end
34
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Avocado
4
4
  class RecoveriesController < BaseController
5
- PERMITTED_PARAMS = %i[password password_confirmation]
5
+ UPDATE_PARAMETERS = %i[password password_confirmation]
6
6
 
7
7
  skip_before_action :authenticate
8
8
 
@@ -12,16 +12,16 @@ module Avocado
12
12
  def new
13
13
  end
14
14
 
15
- def edit
16
- end
17
-
18
15
  def create
19
16
  send_password_reset_email
20
17
  redirect_to new_session_path, notice: "Check your email for reset instructions."
21
18
  end
22
19
 
20
+ def edit
21
+ end
22
+
23
23
  def update
24
- if @user.update(user_params)
24
+ if @user.update(update_parameters)
25
25
  redirect_to new_session_path, notice: "Password reset successfully. Please sign in."
26
26
  else
27
27
  render :edit, status: :unprocessable_entity
@@ -41,23 +41,19 @@ module Avocado
41
41
  end
42
42
 
43
43
  def verify_user
44
- unless user_from_params_email
44
+ unless requested_verified_user
45
45
  redirect_to new_recovery_path, alert: "Verify email first before resetting password."
46
46
  end
47
47
  end
48
48
 
49
- def user_params
49
+ def update_parameters
50
50
  params
51
51
  .require(:user)
52
- .permit(PERMITTED_PARAMS)
53
- end
54
-
55
- def user_from_params_email
56
- ::User.find_by(email: params[:email], verified: true)
52
+ .permit(UPDATE_PARAMETERS)
57
53
  end
58
54
 
59
55
  def send_password_reset_email
60
- mailer_for(user_from_params_email)
56
+ mailer_for(requested_verified_user)
61
57
  .password_reset
62
58
  .deliver_later
63
59
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Avocado
4
4
  class RegistrationsController < BaseController
5
- PERMITTED_PARAMS = %i[email password password_confirmation]
5
+ INITIALIZATION_PARAMETERS = %i[email password password_confirmation]
6
6
 
7
7
  skip_before_action :authenticate
8
8
 
@@ -11,7 +11,7 @@ module Avocado
11
11
  end
12
12
 
13
13
  def create
14
- @user = ::User.new(user_params)
14
+ @user = ::User.new(initialization_parameters)
15
15
 
16
16
  if @user.save
17
17
  sign_in(@user)
@@ -25,10 +25,10 @@ module Avocado
25
25
 
26
26
  private
27
27
 
28
- def user_params
28
+ def initialization_parameters
29
29
  params
30
30
  .require(:user)
31
- .permit(PERMITTED_PARAMS)
31
+ .permit(INITIALIZATION_PARAMETERS)
32
32
  end
33
33
 
34
34
  def send_email_verification
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Avocado
4
4
  class SessionsController < BaseController
5
- PERMITTED_PARAMS = %i[email password]
5
+ AUTHENTICATION_PARAMETERS = %i[email password]
6
6
 
7
7
  skip_before_action :authenticate, only: %i[new create]
8
8
 
@@ -33,15 +33,15 @@ module Avocado
33
33
 
34
34
  private
35
35
 
36
- def session_params
36
+ def authentication_parameters
37
37
  params
38
38
  .require(:session)
39
- .permit(PERMITTED_PARAMS)
39
+ .permit(AUTHENTICATION_PARAMETERS)
40
40
  .with_defaults(email: "", password: "")
41
41
  end
42
42
 
43
43
  def authenticated_user
44
- @_authenticated_user ||= ::User.authenticate_by(session_params)
44
+ @_authenticated_user ||= ::User.authenticate_by(authentication_parameters)
45
45
  end
46
46
 
47
47
  def verify_authentication_attempt
@@ -2,12 +2,15 @@
2
2
 
3
3
  module Avocado
4
4
  class VerificationsController < BaseController
5
- with_options only: :show do
5
+ with_options only: %i[edit update] do
6
6
  skip_before_action :authenticate
7
7
  before_action :set_user
8
8
  end
9
9
 
10
- def show
10
+ def edit
11
+ end
12
+
13
+ def update
11
14
  @user.update! verified: true
12
15
  redirect_to root_path, notice: "Email address verified."
13
16
  end
@@ -0,0 +1,7 @@
1
+ <h2>
2
+ Sign in without password
3
+ </h2>
4
+
5
+ <%= form_with url: affirmation_path(id: params[:id]), method: :patch do |form| %>
6
+ <%= form.button "Submit", name: nil %>
7
+ <% end -%>
@@ -0,0 +1,14 @@
1
+ <h2>
2
+ Sign in without password
3
+ </h2>
4
+
5
+ <p>
6
+ <%= link_to "Reset your password", new_recovery_path %>
7
+ </p>
8
+
9
+ <%= form_with url: affirmations_path, scope: :user do |form| %>
10
+ <%= form.label :email %>
11
+ <%= form.email_field :email, autofocus: true, autocomplete: "email", required: true %>
12
+
13
+ <%= form.button "Submit", name: nil %>
14
+ <% end -%>
@@ -0,0 +1,20 @@
1
+ <h2>
2
+ Change your email address
3
+ </h2>
4
+
5
+ <% if current_user.verified? %>
6
+ <p>Your email address is verified.</p>
7
+ <% else %>
8
+ <p>Your email address is not verified. Check your email and follow the instructions to confirm it's your email address.</p>
9
+ <p><%= button_to "Re-send verification email", verifications_path %></p>
10
+ <% end %>
11
+
12
+ <%= form_with model: @user, url: email_path, method: :patch do |form| %>
13
+ <%= form.label :email %>
14
+ <%= form.email_field :email, autocomplete: "email", required: true %>
15
+
16
+ <%= form.label :password_challenge, "Current password" %>
17
+ <%= form.password_field :password_challenge, autocomplete: "current-password", required: true %>
18
+
19
+ <%= form.button "Submit", name: nil %>
20
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <tr id="<%= dom_id event %>">
2
+ <td><%= event.action %></td>
3
+ <td><%= event.created_at %></td>
4
+ <td><%= truncate event.user_agent %></td>
5
+ <td><%= event.ip_address %></td>
6
+ </tr>
@@ -0,0 +1,17 @@
1
+ <h2>
2
+ Events
3
+ </h2>
4
+
5
+ <table id="events">
6
+ <thead>
7
+ <tr>
8
+ <th scope="col">Action</th>
9
+ <th scope="col">Created</th>
10
+ <th scope="col">User Agent</th>
11
+ <th scope="col">IP Address</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <%= render @events %>
16
+ </tbody>
17
+ </table>
@@ -1 +1,3 @@
1
- Email affirmation email
1
+ Sign in by following this link:
2
+
3
+ <%= edit_affirmation_url(id: @signed_id) %>
@@ -1,3 +1,3 @@
1
- Email verification email
1
+ Verify your email by following this link:
2
2
 
3
- <%= verification_url(id: @signed_id) %>
3
+ <%= edit_verification_url(id: @signed_id) %>
@@ -1,3 +1,3 @@
1
- Password reset email
1
+ Reset your password by following this link:
2
2
 
3
3
  <%= edit_recovery_url(id: @signed_id) %>
@@ -0,0 +1,16 @@
1
+ <h2>
2
+ Change your password
3
+ </h2>
4
+
5
+ <%= form_with model: @user, url: password_path, method: :patch do |form| %>
6
+ <%= form.label :password_challenge, "Current password" %>
7
+ <%= form.password_field :password_challenge, autocomplete: "current-password", required: true %>
8
+
9
+ <%= form.label :password %>
10
+ <%= form.password_field :password, autocomplete: "new-password", required: true %>
11
+
12
+ <%= form.label :password_confirmation %>
13
+ <%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
14
+
15
+ <%= form.button "Submit", name: nil %>
16
+ <% end %>
@@ -1,5 +1,5 @@
1
1
  <h2>
2
- Reset your password
2
+ Change your password
3
3
  </h2>
4
4
 
5
5
  <p>
@@ -13,5 +13,5 @@
13
13
  <%= form.label :password_confirmation %>
14
14
  <%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
15
15
 
16
- <%= form.button "Update password", name: nil %>
16
+ <%= form.button "Submit", name: nil %>
17
17
  <% end %>
@@ -1,14 +1,14 @@
1
1
  <h2>
2
- Recover your password
2
+ Reset your password
3
3
  </h2>
4
4
 
5
5
  <p>
6
6
  <%= link_to "Sign in to your account", new_session_path %>
7
7
  </p>
8
8
 
9
- <%= form_with url: recoveries_path do |form| %>
9
+ <%= form_with url: recoveries_path, scope: :user do |form| %>
10
10
  <%= form.label :email %>
11
11
  <%= form.email_field :email, autofocus: true, autocomplete: "email", required: true %>
12
12
 
13
- <%= form.button "Recover", name: nil %>
13
+ <%= form.button "Submit", name: nil %>
14
14
  <% end -%>
@@ -19,5 +19,5 @@
19
19
  <%= form.label :password_confirmation %>
20
20
  <%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
21
21
  </div>
22
- <%= form.button "Sign up", name: nil %>
22
+ <%= form.button "Submit", name: nil %>
23
23
  <% end %>
@@ -11,5 +11,5 @@
11
11
  <%= form.email_field :email, autofocus: true, autocomplete: "email", required: true %>
12
12
  <%= form.label :password %>
13
13
  <%= form.password_field :password, autocomplete: "current-password", required: true %>
14
- <%= form.button "Sign in", name: nil %>
14
+ <%= form.button "Submit", name: nil %>
15
15
  <% end -%>
@@ -0,0 +1,7 @@
1
+ <h2>
2
+ Verify your email
3
+ </h2>
4
+
5
+ <%= form_with url: verification_path(id: params[:id]), method: :patch do |form| %>
6
+ <%= form.button "Submit", name: nil %>
7
+ <% end -%>
@@ -0,0 +1,10 @@
1
+ scope module: :avocado do
2
+ resource :email, only: %i[edit update]
3
+ resource :password, only: %i[edit update]
4
+ resources :affirmations, only: %i[new create edit update]
5
+ resources :events, only: %i[index]
6
+ resources :recoveries, only: %i[new create edit update]
7
+ resources :registrations, only: %i[new create]
8
+ resources :sessions, only: %i[index new create destroy]
9
+ resources :verifications, only: %i[create edit update]
10
+ end
@@ -0,0 +1 @@
1
+ avocado.rb
data/docs/USAGE.md ADDED
@@ -0,0 +1,155 @@
1
+ # Overview
2
+
3
+ The 🥑 gem is a [Rails Engine] composed of a mixture of Ruby modules that get
4
+ included into application classes and Ruby classes which will run directly from
5
+ the 🥑 gem itself. The classes are intended to provide good defaults for basic
6
+ scenarios, and can be subclassed and overridden for special cases.
7
+
8
+ ## Requirements
9
+
10
+ Apps must be running Rails 7.1 or newer. The 🥑 gem uses features like
11
+ `authenticate_by`, `has_secure_password`, `generates_token_for`, and
12
+ `normalizes` which don't exist in earlier versions.
13
+
14
+ The database schema must have columns that match the `users`, `sessions`, and
15
+ `events` tables from the [demo app schema]. More columns in each table are
16
+ acceptable; the demo is just a minimum. Slight variations (using `uuid` instead
17
+ of `bigint` for example) are harmless, but large departures will break the
18
+ integration.
19
+
20
+ Run `bin/rails g avocado:migrations` to generate migrations for the tables.
21
+
22
+ The application must also have:
23
+
24
+ - An `ApplicationController` base controller class
25
+ - An `ApplicationMailer` base mailer class
26
+ - A `root_path` method (typically generated by application routes)
27
+
28
+ ## Usage
29
+
30
+ ### Models
31
+
32
+ Include these modules into `ActiveRecord` model classes:
33
+
34
+ ```ruby
35
+ class User < ApplicationRecord
36
+ include Avocado::User
37
+ end
38
+
39
+ class Session < ApplicationRecord
40
+ include Avocado::Session
41
+ end
42
+
43
+ class Event < ApplicationRecord
44
+ include Avocado::Event
45
+ end
46
+ ```
47
+
48
+ This will set up some basic associations, validations, callbacks, and
49
+ normalizations for those models.
50
+
51
+ ### Controllers
52
+
53
+ Add the `Avocado::Authentication` module to the top-level controller:
54
+
55
+ ```ruby
56
+ class ApplicationController < ActionController::Base
57
+ include Avocado::Authentication
58
+ end
59
+ ```
60
+
61
+ ### Routes
62
+
63
+ The 🥑 gem does not add any routes to the application when initialized. To hook
64
+ up the controllers to routes, they must be added to the `config/routes.rb` of
65
+ the application. It's possible to add all of the routes, or just a subset.
66
+
67
+ Example that defines a root route and also pulls in every feature route:
68
+
69
+ ```ruby
70
+ Rails.application.routes.draw do
71
+ root to: "records#index"
72
+ draw(🥑)
73
+ end
74
+ ```
75
+
76
+ Example that adds only the sign-up, sign-in, and sign-out actions:
77
+
78
+ ```ruby
79
+ Rails.application.routes.draw do
80
+ root to: "records#index"
81
+ scope module: :avocado do
82
+ resources :registrations, only: %i[new create]
83
+ resources :sessions, only: %i[new create destroy]
84
+ end
85
+ end
86
+ ```
87
+
88
+ ## Summary
89
+
90
+ ### Routable controller features
91
+
92
+ The 🥑 gem will create REST-ful routes that go to the various controllers during
93
+ app initialization.
94
+
95
+ These external (unauthenticated) features are available:
96
+
97
+ - `Registrations` -- Fill out new form and create users
98
+ - `Sessions` -- Sign in and sign out features
99
+ - `Recoveries` -- Trigger password reset, click link, confirm
100
+ - `Verifications` -- Email confirmation on account creation or email change
101
+ - `Affirmations` -- Provides a "passwordless" auth via emailed link
102
+
103
+ These internal (authenticated) features are available:
104
+
105
+ - `Sessions` -- List active sessions, click to destroy untrusted ones
106
+ - `Events` -- List view of user activity audit log
107
+ - `Passwords` -- Edit and update user password
108
+ - `Emails` -- Edit and update user email
109
+
110
+ Linking to any of these internal pages is optional. Apps can use them as-is,
111
+ override their views, or even ignore them entirely and make local versions.
112
+
113
+ ### Mailers
114
+
115
+ There is an `Avocado::Mailer` which gets called to send emails. The mailer views
116
+ here are very basic, and should be overriden within applications. Place views
117
+ within `app/views/avocado/mailer/` to make this happen.
118
+
119
+ ### Before actions
120
+
121
+ There is an `authenticate` method installed as a default `before_action`. Any
122
+ actions which do not need to be authenticated should disable this with
123
+ `skip_before_action`.
124
+
125
+ There is a `set_current_request_details` method installed as a default
126
+ `before_action` which takes some loggable request meta information (user agent,
127
+ IP address) and sets its value in `Current` so that its accesible to code
128
+ elsewhere in the 🥑 gem.
129
+
130
+ ### Helpers
131
+
132
+ The `Avocado::Authentication` module included into the application controller
133
+ provides some helper methods available in controllers, views, and helpers:
134
+
135
+ - `signed_in?` is true if the session has a signed in user
136
+ - `current_session` provides the DB record for the session, if one exists
137
+ - `current_user` returns the user belonging to that session
138
+
139
+ Usage of these can be seen in the views in the [demo app].
140
+
141
+ ## Customization
142
+
143
+ There is not any configuration. To override functionality:
144
+
145
+ - Redefine a method created in one of the models by the included module
146
+ - Subclass a controller and update the routing to go to the subclass
147
+ - Place views in the app where avocado expects them to override the defaults
148
+
149
+ ## Examples
150
+
151
+ There is a [demo app] used by the specs which has some example usage.
152
+
153
+ [demo app schema]: https://github.com/tcuwp/avocado/blob/main/spec/internal/db/schema.rb
154
+ [demo app]: https://github.com/tcuwp/avocado/blob/main/spec/internal
155
+ [Rails Engine]: https://guides.rubyonrails.org/engines.html#what-are-engines-questionmark
@@ -37,12 +37,12 @@ module Avocado
37
37
 
38
38
  def sign_in(user)
39
39
  ::Session.create!(user: user).tap do |session|
40
- cookies.signed.permanent[:session_token] = {value: session.id, httponly: true}
40
+ cookies.signed.permanent[:session_token] = {value: session.token, httponly: true}
41
41
  end
42
42
  end
43
43
 
44
44
  def session_from_token
45
- ::Session.find_by_id(cookies.signed[:session_token])
45
+ ::Session.find_by_token(cookies.signed[:session_token])
46
46
  end
47
47
 
48
48
  def set_current_request_details
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Current < ActiveSupport::CurrentAttributes
4
- attribute :session,
5
- :user,
6
- :user_agent,
7
- :ip_address
3
+ module Avocado
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :session,
6
+ :user,
7
+ :user_agent,
8
+ :ip_address
8
9
 
9
- def session=(session)
10
- super
11
- self.user = session.user
10
+ def session=(session)
11
+ super
12
+ self.user = session.user
13
+ end
12
14
  end
13
15
  end
@@ -5,5 +5,10 @@ require "rails/engine"
5
5
 
6
6
  module Avocado
7
7
  class Engine < Rails::Engine
8
+ initializer :avocado_routing do
9
+ ActiveSupport.on_load(:action_dispatch_request) do
10
+ ActionDispatch::Routing::Mapper.define_method(:🥑) { :🥑 }
11
+ end
12
+ end
8
13
  end
9
14
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module Event
5
+ extend ActiveSupport::Concern
6
+
7
+ VALID_ACTIONS = [
8
+ "email:update",
9
+ "email:verified",
10
+ "password:update",
11
+ "session:create",
12
+ "session:destroy"
13
+ ]
14
+
15
+ included do
16
+ belongs_to :user
17
+
18
+ scope :newest_first, -> { order(created_at: :desc) }
19
+
20
+ validates :action, inclusion: VALID_ACTIONS
21
+
22
+ before_create :capture_request_details
23
+ end
24
+
25
+ private
26
+
27
+ def capture_request_details
28
+ self.user_agent = Current.user_agent
29
+ self.ip_address = Current.ip_address
30
+ end
31
+ end
32
+ end
@@ -4,10 +4,19 @@ module Avocado
4
4
  module Session
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ SECURE_TOKEN_LENGTH = 64
8
+
7
9
  included do
10
+ include SessionCallbacks
11
+
12
+ has_secure_token length: SECURE_TOKEN_LENGTH, on: :initialize
13
+
8
14
  belongs_to :user
9
15
 
16
+ validates :token, presence: true
17
+
10
18
  scope :newest_first, -> { order(created_at: :desc) }
19
+ scope :non_current, -> { where.not(id: Current.session) }
11
20
  end
12
21
  end
13
22
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module SessionCallbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ after_create :record_activity_create
9
+
10
+ after_destroy :record_activity_destroy
11
+
12
+ before_create :capture_request_details
13
+ end
14
+
15
+ private
16
+
17
+ def record_activity_create
18
+ create_user_event "session:create"
19
+ end
20
+
21
+ def record_activity_destroy
22
+ create_user_event "session:destroy"
23
+ end
24
+
25
+ def create_user_event(action)
26
+ user.events.create! action: action
27
+ end
28
+
29
+ def capture_request_details
30
+ self.user_agent = Current.user_agent
31
+ self.ip_address = Current.ip_address
32
+ end
33
+ end
34
+ end
data/lib/avocado/user.rb CHANGED
@@ -5,14 +5,21 @@ module Avocado
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- include UserEmail
8
+ include UserCallbacks
9
9
  include UserTokens
10
- include UserPassword
10
+ include UserValidations
11
11
 
12
- has_many :sessions
12
+ has_secure_password
13
+
14
+ with_options dependent: :destroy do
15
+ has_many :events
16
+ has_many :sessions
17
+ end
13
18
 
14
19
  scope :newest_first, -> { order(created_at: :desc) }
15
20
  scope :verified, -> { where(verified: true) }
21
+
22
+ normalizes :email, with: ->(email) { email.downcase.strip }
16
23
  end
17
24
  end
18
25
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module UserCallbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ with_options if: :password_digest_previously_changed? do
9
+ after_update :destroy_non_current_sessions
10
+ after_update :record_activity_password_update
11
+ end
12
+
13
+ after_update :record_activity_email_update, if: :email_previously_changed?
14
+ after_update :record_activity_email_verified, if: %i[verified_previously_changed? verified?]
15
+
16
+ before_validation :remove_email_verification, if: :email_changed?, on: :update
17
+ end
18
+
19
+ private
20
+
21
+ def record_activity_password_update
22
+ create_event "password:update"
23
+ end
24
+
25
+ def record_activity_email_update
26
+ create_event "email:update"
27
+ end
28
+
29
+ def record_activity_email_verified
30
+ create_event "email:verified"
31
+ end
32
+
33
+ def create_event(action)
34
+ events.create! action: action
35
+ end
36
+
37
+ def remove_email_verification
38
+ self.verified = false
39
+ end
40
+
41
+ def destroy_non_current_sessions
42
+ sessions.non_current.destroy_all
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module UserValidations
5
+ extend ActiveSupport::Concern
6
+
7
+ PASSWORD_FORMAT = /\A(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*\z/x
8
+ PASSWORD_MINIMUM_LENGTH = 8
9
+
10
+ included do
11
+ validates :email,
12
+ presence: true,
13
+ uniqueness: true,
14
+ format: {with: URI::MailTo::EMAIL_REGEXP}
15
+
16
+ validates :password,
17
+ format: {with: PASSWORD_FORMAT},
18
+ length: {minimum: PASSWORD_MINIMUM_LENGTH},
19
+ allow_nil: true
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Avocado
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/avocado.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
3
4
  require_relative "avocado/engine"
4
5
 
5
6
  module Avocado
@@ -7,10 +8,12 @@ module Avocado
7
8
 
8
9
  autoload :Authentication, "avocado/authentication"
9
10
  autoload :Current, "avocado/current"
11
+ autoload :Event, "avocado/event"
10
12
  autoload :Mailer, "avocado/mailer"
11
13
  autoload :Session, "avocado/session"
14
+ autoload :SessionCallbacks, "avocado/session_callbacks"
12
15
  autoload :User, "avocado/user"
13
- autoload :UserEmail, "avocado/user_email"
16
+ autoload :UserCallbacks, "avocado/user_callbacks"
14
17
  autoload :UserTokens, "avocado/user_tokens"
15
- autoload :UserPassword, "avocado/user_password"
18
+ autoload :UserValidations, "avocado/user_validations"
16
19
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Avocado
7
+ class MigrationsGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_migrations
13
+ migration_template "create_users.rb", "#{db_migrate_path}/create_users.rb"
14
+ migration_template "create_sessions.rb", "#{db_migrate_path}/create_sessions.rb"
15
+ migration_template "create_events.rb", "#{db_migrate_path}/create_events.rb"
16
+ end
17
+
18
+ private
19
+
20
+ def primary_and_foreign_key_types
21
+ config = Rails.configuration.generators
22
+ setting = config.options[config.orm][:primary_key_type]
23
+ primary_key_type = setting || :primary_key
24
+ foreign_key_type = setting || :bigint
25
+ [primary_key_type, foreign_key_type]
26
+ end
27
+
28
+ def primary_key_type
29
+ primary_and_foreign_key_types.first
30
+ end
31
+
32
+ def foreign_key_type
33
+ primary_and_foreign_key_types.last
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :events, id: :<%= primary_key_type %> do |t|
4
+ t.references :user, null: false, foreign_key: true, type: :<%= foreign_key_type %>
5
+ t.string :action, null: false
6
+ t.string :user_agent
7
+ t.string :ip_address
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :sessions, id: :<%= primary_key_type %> do |t|
4
+ t.references :user, null: false, foreign_key: true, type: :<%= foreign_key_type %>
5
+ t.string :token, null: false, index: {unique: true}
6
+ t.string :user_agent
7
+ t.string :ip_address
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :users, id: :<%= primary_key_type %> do |t|
4
+ t.string :email, null: false, index: {unique: true}
5
+ t.string :password_digest, null: false
6
+ t.boolean :verified, null: false, default: false, index: true
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: avocado
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Jankowski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-07-19 00:00:00.000000000 Z
11
+ date: 2023-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '3.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '3.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rails
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -38,34 +38,6 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 7.1.0.alpha
41
- - !ruby/object:Gem::Dependency
42
- name: combustion
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '1.3'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '1.3'
55
- - !ruby/object:Gem::Dependency
56
- name: sqlite3
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
41
  description:
70
42
  email:
71
43
  - matt@jankowski.online
@@ -80,33 +52,52 @@ files:
80
52
  - LICENSE.txt
81
53
  - README.md
82
54
  - Rakefile
55
+ - app/controllers/avocado/affirmations_controller.rb
83
56
  - app/controllers/avocado/base_controller.rb
57
+ - app/controllers/avocado/emails_controller.rb
58
+ - app/controllers/avocado/events_controller.rb
59
+ - app/controllers/avocado/passwords_controller.rb
84
60
  - app/controllers/avocado/recoveries_controller.rb
85
61
  - app/controllers/avocado/registrations_controller.rb
86
62
  - app/controllers/avocado/sessions_controller.rb
87
63
  - app/controllers/avocado/verifications_controller.rb
64
+ - app/views/avocado/affirmations/edit.html.erb
65
+ - app/views/avocado/affirmations/new.html.erb
66
+ - app/views/avocado/emails/edit.html.erb
67
+ - app/views/avocado/events/_event.html.erb
68
+ - app/views/avocado/events/index.html.erb
88
69
  - app/views/avocado/mailer/email_affirmation.text.erb
89
70
  - app/views/avocado/mailer/email_verification.text.erb
90
71
  - app/views/avocado/mailer/password_reset.text.erb
72
+ - app/views/avocado/passwords/edit.html.erb
91
73
  - app/views/avocado/recoveries/edit.html.erb
92
74
  - app/views/avocado/recoveries/new.html.erb
93
75
  - app/views/avocado/registrations/new.html.erb
94
76
  - app/views/avocado/sessions/_session.html.erb
95
77
  - app/views/avocado/sessions/index.html.erb
96
78
  - app/views/avocado/sessions/new.html.erb
79
+ - app/views/avocado/verifications/edit.html.erb
97
80
  - config.ru
98
- - config/routes.rb
81
+ - config/routes/avocado.rb
82
+ - "config/routes/\U0001F951.rb"
83
+ - docs/USAGE.md
99
84
  - lib/avocado.rb
100
85
  - lib/avocado/authentication.rb
101
86
  - lib/avocado/current.rb
102
87
  - lib/avocado/engine.rb
88
+ - lib/avocado/event.rb
103
89
  - lib/avocado/mailer.rb
104
90
  - lib/avocado/session.rb
91
+ - lib/avocado/session_callbacks.rb
105
92
  - lib/avocado/user.rb
106
- - lib/avocado/user_email.rb
107
- - lib/avocado/user_password.rb
93
+ - lib/avocado/user_callbacks.rb
108
94
  - lib/avocado/user_tokens.rb
95
+ - lib/avocado/user_validations.rb
109
96
  - lib/avocado/version.rb
97
+ - lib/generators/avocado/migrations/migrations_generator.rb
98
+ - lib/generators/avocado/migrations/templates/create_events.rb.tt
99
+ - lib/generators/avocado/migrations/templates/create_sessions.rb.tt
100
+ - lib/generators/avocado/migrations/templates/create_users.rb.tt
110
101
  - sig/avocado.rbs
111
102
  homepage: https://github.com/tcuwp/avocado
112
103
  licenses:
data/config/routes.rb DELETED
@@ -1,8 +0,0 @@
1
- Rails.application.routes.draw do
2
- scope module: :avocado do
3
- resources :recoveries, only: %i[new create edit update]
4
- resources :registrations, only: %i[new create]
5
- resources :sessions, only: %i[index new create destroy]
6
- resources :verifications, only: %i[show create]
7
- end
8
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Avocado
4
- module UserEmail
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
9
-
10
- normalizes :email, with: ->(email) { email.downcase.strip }
11
- end
12
- end
13
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Avocado
4
- module UserPassword
5
- extend ActiveSupport::Concern
6
-
7
- REQUIRED_FORMAT = /\A(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*\z/x
8
- REQUIRED_LENGTH = 8
9
-
10
- included do
11
- has_secure_password
12
-
13
- validates :password, format: {with: REQUIRED_FORMAT}, length: {minimum: REQUIRED_LENGTH}, allow_nil: true
14
- end
15
- end
16
- end