avocado 0.3.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +29 -27
  4. data/app/controllers/avocado/affirmations_controller.rb +51 -0
  5. data/app/controllers/avocado/base_controller.rb +21 -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 +65 -0
  10. data/app/controllers/avocado/registrations_controller.rb +40 -0
  11. data/app/controllers/avocado/sessions_controller.rb +57 -0
  12. data/app/controllers/avocado/verifications_controller.rb +38 -0
  13. data/app/views/avocado/affirmations/new.html.erb +14 -0
  14. data/app/views/avocado/emails/edit.html.erb +20 -0
  15. data/app/views/avocado/events/_event.html.erb +6 -0
  16. data/app/views/avocado/events/index.html.erb +17 -0
  17. data/app/views/avocado/mailer/email_affirmation.text.erb +3 -0
  18. data/app/views/avocado/mailer/email_verification.text.erb +3 -0
  19. data/app/views/avocado/mailer/password_reset.text.erb +3 -0
  20. data/app/views/avocado/passwords/edit.html.erb +16 -0
  21. data/app/views/avocado/recoveries/edit.html.erb +17 -0
  22. data/app/views/avocado/recoveries/new.html.erb +14 -0
  23. data/app/views/avocado/registrations/new.html.erb +23 -0
  24. data/app/views/avocado/sessions/_session.html.erb +8 -0
  25. data/app/views/avocado/sessions/index.html.erb +21 -0
  26. data/app/views/avocado/sessions/new.html.erb +15 -0
  27. data/config/routes.rb +12 -0
  28. data/lib/avocado/authentication.rb +53 -0
  29. data/lib/avocado/current.rb +15 -0
  30. data/lib/avocado/engine.rb +9 -0
  31. data/lib/avocado/event.rb +32 -0
  32. data/lib/avocado/mailer.rb +4 -10
  33. data/lib/avocado/session.rb +16 -0
  34. data/lib/avocado/session_callbacks.rb +34 -0
  35. data/lib/avocado/user.rb +15 -7
  36. data/lib/avocado/user_callbacks.rb +45 -0
  37. data/lib/avocado/user_tokens.rb +33 -0
  38. data/lib/avocado/user_validations.rb +22 -0
  39. data/lib/avocado/version.rb +1 -1
  40. data/lib/avocado.rb +9 -6
  41. metadata +39 -11
  42. data/lib/avocado/user_email.rb +0 -15
  43. data/lib/avocado/user_email_affirmation.rb +0 -15
  44. data/lib/avocado/user_email_verification.rb +0 -17
  45. data/lib/avocado/user_password.rb +0 -18
  46. data/lib/avocado/user_password_reset.rb +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04228885896e7d922d727e6497a22a933f3910c96afad40b69bd21d209ab4a3c'
4
- data.tar.gz: 8a71cb118b5d97513f506d44ee5b2e01a0af5747d9e8151c9d866a589565d660
3
+ metadata.gz: a530ebf9605c2bb861d2da09da546513e68c4f647fc97c80686e07b32985cabe
4
+ data.tar.gz: 3be481be5c31ce2ee3bb1ea1a2d796bb7f8cbe850f7ccf2de6f296384a656bb2
5
5
  SHA512:
6
- metadata.gz: 6ff80323ec0979cbfecaa008f7cca93a73c7e2e82df4ac5590caae93de5b3b903c9fc724ab8508c50d70299d118df94718d6c22d00b0f990798a0a3544091a7f
7
- data.tar.gz: d39393b42cfc22511acecaceacce2dc345a65a1500fe3071c7b69f11876f479eb48d9d03beee052a0518b485da2ded599b6fe919454df7bb547a4e2d3ac67664
6
+ metadata.gz: cf3320eb985cc65c05c2da2d007697b4b9016d9a85f7ebebbd0735d744d7f8ca77096d86182c6761b63ea6f51f4b942a3c2dd363bb102ccc4ed53da6f5702c4f
7
+ data.tar.gz: f0b021b9e0f3433f2034e5cccdc751a446b50bc870474376e1a39b84e973bbabfaef95b59d96f60986bbddc7695afe88b860bd2d1568f991a17b3b654d7f3995
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2023-07-21
4
+
5
+ - Add controller for "passwordless" email-link sign-in
6
+ - Add event class to log user auth events
7
+ - Add user-facing email and password edit pages
8
+ - Add various event logging callbacks
9
+ - Sign out all non current sessions when password changes
10
+
11
+ ## [0.4.0] - 2023-07-19
12
+
13
+ - Convert the `Avocado::Mailer` module into a class
14
+ - Add controllers for signing up, signing in, password reset and email
15
+ verification
16
+
3
17
  ## [0.3.0] - 2023-07-17
4
18
 
5
19
  - Add an `Avocado::Mailer` which generates each of the signed ids
data/README.md CHANGED
@@ -4,42 +4,49 @@ A collection of authentication tools for use in [Rails] 7.1+ applications.
4
4
 
5
5
  ## Installation
6
6
 
7
- With bundler, add to the application's Gemfile by executing:
7
+ Add to the application's Gemfile by executing:
8
8
 
9
9
  $ bundle add avocado
10
10
 
11
- Without bundler, install the gem by executing:
11
+ ## Usage
12
12
 
13
- $ gem install avocado
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.
14
16
 
15
- ## Usage
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.
16
20
 
17
- If you have a `User` model in your application and are nervous about using Rails
18
- features directly, preferring to consume the features via a packaged gem, add
19
- the `Avocado::User` to your `User` model:
21
+ With that set, include the modules into your classes:
20
22
 
21
23
  ```ruby
22
24
  class User < ApplicationRecord
23
25
  include Avocado::User
24
26
  end
25
- ```
26
27
 
27
- This will do a few things behind the scenes:
28
+ class Session < ApplicationRecord
29
+ include Avocado::Session
30
+ end
28
31
 
29
- - Use the built-in `has_secure_password` to generate relevant password methods
30
- - Add some basic validations for the `email` and `password` fields on `User`
31
- - Normalize email values when records are saved
32
- - Provide signed token generators for `password_reset`, `email_verification`,
33
- and `email_affirmation`
32
+ class Event < ApplicationRecord
33
+ include Avocado::Event
34
+ end
34
35
 
35
- It's sort of funny to do this because you genuinely just could have put this
36
- stuff right in your app, and yet here we are making gems instead!
36
+ class ApplicationController < ActionController::Base
37
+ include Avocado::Authentication
38
+ end
39
+ ```
37
40
 
38
- There is an `Avocado::Mailer` which can be included in a mailer class and
39
- provides some basic mailers.
41
+ This will enable a few things:
40
42
 
41
- The `spec/internal` app within this repo has some example usage of both model
42
- and mailer.
43
+ - Models will get validations, associations, and normalizations
44
+ - Rails built-in `has_secure_password` is called within `User`
45
+ - A mailer with signed token generators is created
46
+ - Controllers and Routes for sign up, sign in, password reset, email
47
+ verification, etc
48
+
49
+ The `spec/internal` app within this repo has some example usage.
43
50
 
44
51
  ## Development
45
52
 
@@ -47,11 +54,6 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
47
54
  `rake spec` to run the tests. You can also run `bin/console` for an interactive
48
55
  prompt that will allow you to experiment.
49
56
 
50
- To install this gem onto your local machine, run `bundle exec rake install`. To
51
- release a new version, update the version number in `version.rb`, and then run
52
- `bundle exec rake release`, which will create a git tag for the version, push
53
- git commits and the created tag, and push the `.gem` file to [RubyGems].
54
-
55
57
  ## Contributing
56
58
 
57
59
  Bug reports and pull requests are welcome on [GitHub].
@@ -60,7 +62,7 @@ Bug reports and pull requests are welcome on [GitHub].
60
62
 
61
63
  The gem is available as open source under the terms of the [MIT License].
62
64
 
63
- [GitHub]: https://github.com/unicorngroomers/avocado
65
+ [GitHub]: https://github.com/tcuwp/avocado
64
66
  [MIT License]: https://opensource.org/licenses/MIT
65
67
  [Rails]: https://github.com/rails/rails
66
- [RubyGems]: https://rubygems.org
68
+ [the demo app schema]: https://github.com/tcuwp/avocado/blob/main/spec/internal/db/schema.rb
@@ -0,0 +1,51 @@
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: :show
8
+ before_action :verify_user, only: :create
9
+
10
+ def new
11
+ end
12
+
13
+ def show
14
+ sign_in(@user)
15
+ redirect_to(root_path, notice: "Signed in successfully")
16
+ end
17
+
18
+ def create
19
+ send_affirmation_email
20
+ redirect_to new_session_path, notice: "Check your email for sign in instructions"
21
+ end
22
+
23
+ private
24
+
25
+ def set_user
26
+ @user = user_from_signed_affirmation_token
27
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
28
+ redirect_to new_affirmation_path, alert: "That sign in link is invalid"
29
+ end
30
+
31
+ def user_from_signed_affirmation_token
32
+ ::User.find_by_token_for!(:email_affirmation, params[:id])
33
+ end
34
+
35
+ def verify_user
36
+ unless user_from_params_email
37
+ redirect_to new_affirmation_path, alert: "You can't sign in until you verify your email"
38
+ end
39
+ end
40
+
41
+ def send_affirmation_email
42
+ mailer_for(user_from_params_email)
43
+ .email_affirmation
44
+ .deliver_later
45
+ end
46
+
47
+ def user_from_params_email
48
+ ::User.verified.find_by(email: params[:email])
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class BaseController < ApplicationController
5
+ private
6
+
7
+ def verify_password_challenge
8
+ unless current_user.authenticate(params_password_challenge)
9
+ redirect_back alert: "Password challenge failed.", fallback_location: root_path
10
+ end
11
+ end
12
+
13
+ def params_password_challenge
14
+ params.dig(:user, :password_challenge)
15
+ end
16
+
17
+ def mailer_for(user)
18
+ Avocado::Mailer.with(user: user)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class EmailsController < BaseController
5
+ PERMITTED_PARAMS = [: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(user_params)
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 user_params
28
+ params
29
+ .require(:user)
30
+ .permit(PERMITTED_PARAMS)
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
+ PERMITTED_PARAMS = [: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(user_params)
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 user_params
28
+ params
29
+ .require(:user)
30
+ .permit(PERMITTED_PARAMS)
31
+ .with_defaults(password_challenge: "")
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class RecoveriesController < BaseController
5
+ PERMITTED_PARAMS = %i[password password_confirmation]
6
+
7
+ skip_before_action :authenticate
8
+
9
+ before_action :set_user, only: %i[edit update]
10
+ before_action :verify_user, only: :create
11
+
12
+ def new
13
+ end
14
+
15
+ def edit
16
+ end
17
+
18
+ def create
19
+ send_password_reset_email
20
+ redirect_to new_session_path, notice: "Check your email for reset instructions."
21
+ end
22
+
23
+ def update
24
+ if @user.update(user_params)
25
+ redirect_to new_session_path, notice: "Password reset successfully. Please sign in."
26
+ else
27
+ render :edit, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def set_user
34
+ @user = user_from_signed_password_reset_token
35
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
36
+ redirect_to new_recovery_path, alert: "Password reset link is invalid."
37
+ end
38
+
39
+ def user_from_signed_password_reset_token
40
+ ::User.find_by_token_for!(:password_reset, params[:id])
41
+ end
42
+
43
+ def verify_user
44
+ unless user_from_params_email
45
+ redirect_to new_recovery_path, alert: "Verify email first before resetting password."
46
+ end
47
+ end
48
+
49
+ def user_params
50
+ params
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)
57
+ end
58
+
59
+ def send_password_reset_email
60
+ mailer_for(user_from_params_email)
61
+ .password_reset
62
+ .deliver_later
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class RegistrationsController < BaseController
5
+ PERMITTED_PARAMS = %i[email password password_confirmation]
6
+
7
+ skip_before_action :authenticate
8
+
9
+ def new
10
+ @user = ::User.new
11
+ end
12
+
13
+ def create
14
+ @user = ::User.new(user_params)
15
+
16
+ if @user.save
17
+ sign_in(@user)
18
+
19
+ send_email_verification
20
+ redirect_to root_path, notice: "Registration successful"
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def user_params
29
+ params
30
+ .require(:user)
31
+ .permit(PERMITTED_PARAMS)
32
+ end
33
+
34
+ def send_email_verification
35
+ mailer_for(@user)
36
+ .email_verification
37
+ .deliver_later
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class SessionsController < BaseController
5
+ PERMITTED_PARAMS = %i[email password]
6
+
7
+ skip_before_action :authenticate, only: %i[new create]
8
+
9
+ with_options only: :create do
10
+ before_action :verify_authentication_attempt
11
+ end
12
+
13
+ before_action :set_session, only: :destroy
14
+
15
+ def index
16
+ @sessions = current_user.sessions.newest_first
17
+ end
18
+
19
+ def new
20
+ @session = ::Session.new
21
+ end
22
+
23
+ def create
24
+ sign_in(authenticated_user)
25
+
26
+ redirect_to root_path, notice: "Session created"
27
+ end
28
+
29
+ def destroy
30
+ @session.destroy
31
+ redirect_to sessions_path, notice: "Session destroyed"
32
+ end
33
+
34
+ private
35
+
36
+ def session_params
37
+ params
38
+ .require(:session)
39
+ .permit(PERMITTED_PARAMS)
40
+ .with_defaults(email: "", password: "")
41
+ end
42
+
43
+ def authenticated_user
44
+ @_authenticated_user ||= ::User.authenticate_by(session_params)
45
+ end
46
+
47
+ def verify_authentication_attempt
48
+ if authenticated_user.blank?
49
+ redirect_to new_session_path, alert: "Authentication failed"
50
+ end
51
+ end
52
+
53
+ def set_session
54
+ @session = current_user.sessions.find(params[:id])
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class VerificationsController < BaseController
5
+ with_options only: :show do
6
+ skip_before_action :authenticate
7
+ before_action :set_user
8
+ end
9
+
10
+ def show
11
+ @user.update! verified: true
12
+ redirect_to root_path, notice: "Email address verified."
13
+ end
14
+
15
+ def create
16
+ send_email_verification
17
+ redirect_to root_path, notice: "Verification email sent to your address."
18
+ end
19
+
20
+ private
21
+
22
+ def set_user
23
+ @user = user_from_signed_email_verification_token
24
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
25
+ redirect_to root_path, alert: "Email verification link is invalid."
26
+ end
27
+
28
+ def user_from_signed_email_verification_token
29
+ ::User.find_by_token_for!(:email_verification, params[:id])
30
+ end
31
+
32
+ def send_email_verification
33
+ mailer_for(current_user)
34
+ .email_verification
35
+ .deliver_later
36
+ end
37
+ end
38
+ 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 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>
@@ -0,0 +1,3 @@
1
+ Sign in by following this link:
2
+
3
+ <%= affirmation_url(id: @signed_id) %>
@@ -0,0 +1,3 @@
1
+ Verify your email by following this link:
2
+
3
+ <%= verification_url(id: @signed_id) %>
@@ -0,0 +1,3 @@
1
+ Reset your password by following this link:
2
+
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 %>
@@ -0,0 +1,17 @@
1
+ <h2>
2
+ Change your password
3
+ </h2>
4
+
5
+ <p>
6
+ <%= link_to "Sign in to your account" %>
7
+ </p>
8
+
9
+ <%= form_with model: @user, url: recovery_path(id: params[:id]), method: :patch do |form| %>
10
+ <%= form.label :password %>
11
+ <%= form.password_field :password, autocomplete: "new-password", required: true %>
12
+
13
+ <%= form.label :password_confirmation %>
14
+ <%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
15
+
16
+ <%= form.button "Submit", name: nil %>
17
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <h2>
2
+ Reset your password
3
+ </h2>
4
+
5
+ <p>
6
+ <%= link_to "Sign in to your account", new_session_path %>
7
+ </p>
8
+
9
+ <%= form_with url: recoveries_path 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,23 @@
1
+ <h2>
2
+ Sign up for a new account
3
+ </h2>
4
+
5
+ <p>
6
+ <%= link_to "Sign in to an existing account", new_session_path %>
7
+ </p>
8
+
9
+ <%= form_with model: @user, url: registrations_path do |form| %>
10
+ <div>
11
+ <%= form.label :email %>
12
+ <%= form.email_field :email, autofocus: true, autocomplete: "email", required: true %>
13
+ </div>
14
+ <div>
15
+ <%= form.label :password %>
16
+ <%= form.password_field :password, autocomplete: "new-password", required: true %>
17
+ </div>
18
+ <div>
19
+ <%= form.label :password_confirmation %>
20
+ <%= form.password_field :password_confirmation, autocomplete: "new-password", required: true %>
21
+ </div>
22
+ <%= form.button "Submit", name: nil %>
23
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <tr id="<%= dom_id session %>">
2
+ <td><%= truncate session.user_agent %></td>
3
+ <td><%= session.ip_address %></td>
4
+ <td><%= session.created_at %></td>
5
+ <td>
6
+ <%= button_to "Delete", session, method: :delete %>
7
+ </td>
8
+ </tr>
@@ -0,0 +1,21 @@
1
+ <h2>
2
+ Sessions
3
+ </h2>
4
+
5
+ <div>
6
+ <table id="sessions">
7
+ <thead>
8
+ <tr>
9
+ <th scope="col">User Agent</th>
10
+ <th scope="col">IP Address</th>
11
+ <th scope="col">Created</th>
12
+ <th scope="col">
13
+ <span class="sr-only">Edit</span>
14
+ </th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <%= render @sessions %>
19
+ </tbody>
20
+ </table>
21
+ </div>
@@ -0,0 +1,15 @@
1
+ <h2>
2
+ Sign in to your account
3
+ </h2>
4
+
5
+ <p>
6
+ <%= link_to "sign up for a new account", new_registration_path %>
7
+ </p>
8
+
9
+ <%= form_with model: @session do |form| %>
10
+ <%= form.label :email %>
11
+ <%= form.email_field :email, autofocus: true, autocomplete: "email", required: true %>
12
+ <%= form.label :password %>
13
+ <%= form.password_field :password, autocomplete: "current-password", required: true %>
14
+ <%= form.button "Submit", name: nil %>
15
+ <% end -%>
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ Rails.application.routes.draw do
2
+ scope module: :avocado do
3
+ resource :email, only: %i[edit update]
4
+ resource :password, only: %i[edit update]
5
+ resources :affirmations, only: %i[new show create]
6
+ resources :events, only: %i[index]
7
+ resources :recoveries, only: %i[new create edit update]
8
+ resources :registrations, only: %i[new create]
9
+ resources :sessions, only: %i[index new create destroy]
10
+ resources :verifications, only: %i[show create]
11
+ end
12
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module Authentication
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :set_current_request_details
9
+ before_action :authenticate
10
+
11
+ helper_method :current_user
12
+ helper_method :signed_in?
13
+ helper_method :current_session
14
+ end
15
+
16
+ def current_user
17
+ Current.user
18
+ end
19
+
20
+ def signed_in?
21
+ current_user.present?
22
+ end
23
+
24
+ def current_session
25
+ Current.session
26
+ end
27
+
28
+ private
29
+
30
+ def authenticate
31
+ if session_from_token
32
+ Current.session = session_from_token
33
+ else
34
+ redirect_to new_session_path
35
+ end
36
+ end
37
+
38
+ def sign_in(user)
39
+ ::Session.create!(user: user).tap do |session|
40
+ cookies.signed.permanent[:session_token] = {value: session.id, httponly: true}
41
+ end
42
+ end
43
+
44
+ def session_from_token
45
+ ::Session.find_by_id(cookies.signed[:session_token])
46
+ end
47
+
48
+ def set_current_request_details
49
+ Current.user_agent = request.user_agent
50
+ Current.ip_address = request.ip
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :session,
6
+ :user,
7
+ :user_agent,
8
+ :ip_address
9
+
10
+ def session=(session)
11
+ super
12
+ self.user = session.user
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "avocado"
4
+ require "rails/engine"
5
+
6
+ module Avocado
7
+ class Engine < Rails::Engine
8
+ end
9
+ 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
@@ -1,17 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/concern"
4
-
5
3
  module Avocado
6
- module Mailer
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- before_action :set_user
11
- before_action :set_signed_id
4
+ class Mailer < ApplicationMailer
5
+ before_action :set_user
6
+ before_action :set_signed_id
12
7
 
13
- default to: -> { @user.email }
14
- end
8
+ default to: -> { @user.email }
15
9
 
16
10
  def email_affirmation
17
11
  mail
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module Session
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include SessionCallbacks
9
+
10
+ belongs_to :user
11
+
12
+ scope :newest_first, -> { order(created_at: :desc) }
13
+ scope :non_current, -> { where.not(id: Current.session) }
14
+ end
15
+ end
16
+ 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
@@ -1,17 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/concern"
4
-
5
3
  module Avocado
6
4
  module User
7
5
  extend ActiveSupport::Concern
8
6
 
9
7
  included do
10
- include UserEmail
11
- include UserEmailAffirmation
12
- include UserEmailVerification
13
- include UserPassword
14
- include UserPasswordReset
8
+ include UserCallbacks
9
+ include UserTokens
10
+ include UserValidations
11
+
12
+ has_secure_password
13
+
14
+ with_options dependent: :destroy do
15
+ has_many :events
16
+ has_many :sessions
17
+ end
18
+
19
+ scope :newest_first, -> { order(created_at: :desc) }
20
+ scope :verified, -> { where(verified: true) }
21
+
22
+ normalizes :email, with: ->(email) { email.downcase.strip }
15
23
  end
16
24
  end
17
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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Avocado
4
+ module UserTokens
5
+ extend ActiveSupport::Concern
6
+
7
+ EXPIRES_FAST = 16.minutes
8
+ EXPIRES_LATER = 64.minutes
9
+ EXPIRES_LONG = 2_048.minutes
10
+
11
+ included do
12
+ generates_token_for :email_affirmation, expires_in: EXPIRES_FAST
13
+
14
+ generates_token_for :email_verification, expires_in: EXPIRES_LONG do
15
+ email
16
+ end
17
+
18
+ generates_token_for :password_reset, expires_in: EXPIRES_LATER do
19
+ password_digest_salt
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def password_digest_salt
26
+ password_from_digest.salt[-10..]
27
+ end
28
+
29
+ def password_from_digest
30
+ BCrypt::Password.new(password_digest)
31
+ end
32
+ end
33
+ 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.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/avocado.rb CHANGED
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "avocado/version"
3
+ require_relative "avocado/engine"
4
4
 
5
5
  module Avocado
6
6
  class Error < StandardError; end
7
7
 
8
+ autoload :Authentication, "avocado/authentication"
9
+ autoload :Current, "avocado/current"
10
+ autoload :Event, "avocado/event"
8
11
  autoload :Mailer, "avocado/mailer"
12
+ autoload :Session, "avocado/session"
13
+ autoload :SessionCallbacks, "avocado/session_callbacks"
9
14
  autoload :User, "avocado/user"
10
- autoload :UserEmail, "avocado/user_email"
11
- autoload :UserEmailAffirmation, "avocado/user_email_affirmation"
12
- autoload :UserEmailVerification, "avocado/user_email_verification"
13
- autoload :UserPassword, "avocado/user_password"
14
- autoload :UserPasswordReset, "avocado/user_password_reset"
15
+ autoload :UserCallbacks, "avocado/user_callbacks"
16
+ autoload :UserTokens, "avocado/user_tokens"
17
+ autoload :UserValidations, "avocado/user_validations"
15
18
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: avocado
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.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-18 00:00:00.000000000 Z
11
+ date: 2023-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt
@@ -80,24 +80,52 @@ files:
80
80
  - LICENSE.txt
81
81
  - README.md
82
82
  - Rakefile
83
+ - app/controllers/avocado/affirmations_controller.rb
84
+ - app/controllers/avocado/base_controller.rb
85
+ - app/controllers/avocado/emails_controller.rb
86
+ - app/controllers/avocado/events_controller.rb
87
+ - app/controllers/avocado/passwords_controller.rb
88
+ - app/controllers/avocado/recoveries_controller.rb
89
+ - app/controllers/avocado/registrations_controller.rb
90
+ - app/controllers/avocado/sessions_controller.rb
91
+ - app/controllers/avocado/verifications_controller.rb
92
+ - app/views/avocado/affirmations/new.html.erb
93
+ - app/views/avocado/emails/edit.html.erb
94
+ - app/views/avocado/events/_event.html.erb
95
+ - app/views/avocado/events/index.html.erb
96
+ - app/views/avocado/mailer/email_affirmation.text.erb
97
+ - app/views/avocado/mailer/email_verification.text.erb
98
+ - app/views/avocado/mailer/password_reset.text.erb
99
+ - app/views/avocado/passwords/edit.html.erb
100
+ - app/views/avocado/recoveries/edit.html.erb
101
+ - app/views/avocado/recoveries/new.html.erb
102
+ - app/views/avocado/registrations/new.html.erb
103
+ - app/views/avocado/sessions/_session.html.erb
104
+ - app/views/avocado/sessions/index.html.erb
105
+ - app/views/avocado/sessions/new.html.erb
83
106
  - config.ru
107
+ - config/routes.rb
84
108
  - lib/avocado.rb
109
+ - lib/avocado/authentication.rb
110
+ - lib/avocado/current.rb
111
+ - lib/avocado/engine.rb
112
+ - lib/avocado/event.rb
85
113
  - lib/avocado/mailer.rb
114
+ - lib/avocado/session.rb
115
+ - lib/avocado/session_callbacks.rb
86
116
  - lib/avocado/user.rb
87
- - lib/avocado/user_email.rb
88
- - lib/avocado/user_email_affirmation.rb
89
- - lib/avocado/user_email_verification.rb
90
- - lib/avocado/user_password.rb
91
- - lib/avocado/user_password_reset.rb
117
+ - lib/avocado/user_callbacks.rb
118
+ - lib/avocado/user_tokens.rb
119
+ - lib/avocado/user_validations.rb
92
120
  - lib/avocado/version.rb
93
121
  - sig/avocado.rbs
94
- homepage: https://github.com/unicorngroomers/avocado
122
+ homepage: https://github.com/tcuwp/avocado
95
123
  licenses:
96
124
  - MIT
97
125
  metadata:
98
- homepage_uri: https://github.com/unicorngroomers/avocado
99
- source_code_uri: https://github.com/unicorngroomers/avocado
100
- changelog_uri: https://github.com/unicorngroomers/avocado/blob/main/CHANGELOG.md
126
+ homepage_uri: https://github.com/tcuwp/avocado
127
+ source_code_uri: https://github.com/tcuwp/avocado
128
+ changelog_uri: https://github.com/tcuwp/avocado/blob/main/CHANGELOG.md
101
129
  post_install_message:
102
130
  rdoc_options: []
103
131
  require_paths:
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Avocado
6
- module UserEmail
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
11
-
12
- normalizes :email, with: ->(email) { email.downcase.strip }
13
- end
14
- end
15
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Avocado
6
- module UserEmailAffirmation
7
- extend ActiveSupport::Concern
8
-
9
- TOKEN_EXPIRATION = 16.minutes
10
-
11
- included do
12
- generates_token_for :email_affirmation, expires_in: TOKEN_EXPIRATION
13
- end
14
- end
15
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Avocado
6
- module UserEmailVerification
7
- extend ActiveSupport::Concern
8
-
9
- TOKEN_EXPIRATION = 2_048.minutes
10
-
11
- included do
12
- generates_token_for :email_verification, expires_in: TOKEN_EXPIRATION do
13
- email
14
- end
15
- end
16
- end
17
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Avocado
6
- module UserPassword
7
- extend ActiveSupport::Concern
8
-
9
- REQUIRED_FORMAT = /\A(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*\z/x
10
- REQUIRED_LENGTH = 8
11
-
12
- included do
13
- has_secure_password
14
-
15
- validates :password, format: {with: REQUIRED_FORMAT}, length: {minimum: REQUIRED_LENGTH}, allow_nil: true
16
- end
17
- end
18
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Avocado
6
- module UserPasswordReset
7
- extend ActiveSupport::Concern
8
-
9
- TOKEN_EXPIRATION = 64.minutes
10
-
11
- included do
12
- generates_token_for :password_reset, expires_in: TOKEN_EXPIRATION do
13
- password_digest_salt
14
- end
15
- end
16
-
17
- private
18
-
19
- def password_digest_salt
20
- password_from_digest.salt[-10..]
21
- end
22
-
23
- def password_from_digest
24
- BCrypt::Password.new(password_digest)
25
- end
26
- end
27
- end