avocado 0.3.0 → 0.5.0

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