avocado 0.4.0 → 0.6.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.
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