better_authy 0.1.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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +123 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/better_authy/base_controller.rb +39 -0
  6. data/app/controllers/better_authy/passwords_controller.rb +92 -0
  7. data/app/controllers/better_authy/registrations_controller.rb +27 -0
  8. data/app/controllers/better_authy/sessions_controller.rb +48 -0
  9. data/app/helpers/better_authy/application_helper.rb +7 -0
  10. data/app/mailers/better_authy/application_mailer.rb +13 -0
  11. data/app/mailers/better_authy/password_reset_mailer.rb +20 -0
  12. data/app/models/better_authy/forgot_password_form.rb +12 -0
  13. data/app/models/better_authy/reset_password_form.rb +15 -0
  14. data/app/models/better_authy/session_form.rb +15 -0
  15. data/app/views/better_authy/password_reset_mailer/reset_password_instructions.html.erb +13 -0
  16. data/app/views/better_authy/password_reset_mailer/reset_password_instructions.text.erb +9 -0
  17. data/app/views/better_authy/passwords/edit.html.erb +55 -0
  18. data/app/views/better_authy/passwords/new.html.erb +61 -0
  19. data/app/views/better_authy/registrations/new.html.erb +59 -0
  20. data/app/views/better_authy/sessions/new.html.erb +80 -0
  21. data/app/views/layouts/better_authy/application.html.erb +20 -0
  22. data/config/locales/en.yml +48 -0
  23. data/config/locales/it.yml +48 -0
  24. data/config/routes.rb +23 -0
  25. data/lib/better_authy/configuration.rb +43 -0
  26. data/lib/better_authy/controller_helpers.rb +97 -0
  27. data/lib/better_authy/engine.rb +11 -0
  28. data/lib/better_authy/errors.rb +6 -0
  29. data/lib/better_authy/model_extensions.rb +23 -0
  30. data/lib/better_authy/models/authenticable.rb +118 -0
  31. data/lib/better_authy/scope_configuration.rb +30 -0
  32. data/lib/better_authy/version.rb +5 -0
  33. data/lib/better_authy.rb +37 -0
  34. metadata +137 -0
@@ -0,0 +1,80 @@
1
+ <% content_for :title, t("better_authy.sessions.title") %>
2
+
3
+ <div class="w-full max-w-md">
4
+ <% if flash[:alert].present? %>
5
+ <%= bui_action_messages([flash[:alert]],
6
+ variant: :danger,
7
+ style: :soft,
8
+ dismissible: true,
9
+ container_classes: "mb-6"
10
+ ) %>
11
+ <% end %>
12
+
13
+ <% if flash[:notice].present? %>
14
+ <%= bui_action_messages([flash[:notice]],
15
+ variant: :success,
16
+ style: :soft,
17
+ dismissible: true,
18
+ container_classes: "mb-6"
19
+ ) %>
20
+ <% end %>
21
+
22
+ <%= bui_card(
23
+ variant: :light,
24
+ style: :bordered,
25
+ size: :lg,
26
+ shadow: true
27
+ ) do |card| %>
28
+ <% card.with_header do %>
29
+ <h1 class="text-2xl font-bold text-center text-grayscale-900">
30
+ <%= t("better_authy.sessions.title") %>
31
+ </h1>
32
+ <% end %>
33
+
34
+ <% card.with_body do %>
35
+ <%= form_with model: @session_form, scope: :session, url: request.path, builder: BetterUi::UiFormBuilder, class: "space-y-6" do |f| %>
36
+ <%= f.bui_text_input :email,
37
+ label: t("better_authy.attributes.email"),
38
+ placeholder: t("better_authy.placeholders.email"),
39
+ type: "email",
40
+ autocomplete: "email" %>
41
+
42
+ <%= f.bui_password_input :password,
43
+ label: t("better_authy.attributes.password"),
44
+ placeholder: t("better_authy.placeholders.password"),
45
+ autocomplete: "current-password" %>
46
+
47
+ <div class="flex items-center justify-between">
48
+ <%= f.bui_checkbox :remember_me,
49
+ label: t("better_authy.sessions.remember_me") %>
50
+
51
+ <%= link_to t("better_authy.sessions.forgot_password"),
52
+ send(:"new_#{params[:scope]}_password_path"),
53
+ class: "text-sm font-medium text-primary-600 hover:text-primary-500" %>
54
+ </div>
55
+
56
+ <div>
57
+ <%= bui_button(
58
+ variant: :primary,
59
+ style: :solid,
60
+ size: :lg,
61
+ type: :submit,
62
+ show_loader_on_click: true,
63
+ container_classes: "w-full"
64
+ ) do %>
65
+ <%= t("better_authy.sessions.submit") %>
66
+ <% end %>
67
+ </div>
68
+ <% end %>
69
+ <% end %>
70
+
71
+ <% card.with_footer do %>
72
+ <p class="text-center text-sm text-grayscale-600">
73
+ <%= t("better_authy.sessions.no_account") %>
74
+ <%= link_to t("better_authy.sessions.signup_link"),
75
+ send(:"#{params[:scope]}_signup_path"),
76
+ class: "font-medium text-primary-600 hover:text-primary-500" %>
77
+ </p>
78
+ <% end %>
79
+ <% end %>
80
+ </div>
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || "Authentication" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= yield :head %>
10
+
11
+ <%= vite_stylesheet_link_tag "application.css" if defined?(vite_stylesheet_link_tag) %>
12
+ <%= vite_javascript_include_tag "application.js" if defined?(vite_javascript_include_tag) %>
13
+ </head>
14
+
15
+ <body class="bg-grayscale-50 min-h-screen">
16
+ <div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
17
+ <%= yield %>
18
+ </div>
19
+ </body>
20
+ </html>
@@ -0,0 +1,48 @@
1
+ en:
2
+ better_authy:
3
+ sessions:
4
+ title: "Sign In"
5
+ submit: "Sign In"
6
+ remember_me: "Remember me"
7
+ signed_in: "Signed in successfully."
8
+ signed_out: "Signed out successfully."
9
+ invalid_credentials: "Invalid email or password."
10
+ unauthenticated: "You need to sign in to continue."
11
+ no_account: "Don't have an account?"
12
+ signup_link: "Sign up"
13
+ forgot_password: "Forgot password?"
14
+ registrations:
15
+ title: "Sign Up"
16
+ submit: "Create account"
17
+ errors_title: "Please correct the following errors:"
18
+ signed_up: "Welcome! You have signed up successfully."
19
+ has_account: "Already have an account?"
20
+ login_link: "Sign in"
21
+ attributes:
22
+ email: "Email"
23
+ password: "Password"
24
+ password_confirmation: "Password confirmation"
25
+ placeholders:
26
+ email: "you@example.com"
27
+ password: "Enter your password"
28
+ password_confirmation: "Confirm your password"
29
+ hints:
30
+ password: "Minimum 8 characters"
31
+ passwords:
32
+ forgot_title: "Forgot Password"
33
+ forgot_instruction: "Enter your email address and we'll send you instructions to reset your password."
34
+ forgot_submit: "Send Reset Instructions"
35
+ remembered: "Remember your password?"
36
+ reset_title: "Reset Password"
37
+ reset_submit: "Reset Password"
38
+ send_instructions: "If your email address exists in our database, you will receive password reset instructions shortly."
39
+ updated: "Your password has been changed successfully. You can now sign in."
40
+ invalid_token: "The password reset link is invalid or has expired."
41
+ no_token: "No password reset token provided."
42
+ mailer:
43
+ subject: "Password Reset Instructions"
44
+ greeting: "Hello %{email}!"
45
+ instruction: "Someone has requested a link to change your password. You can do this through the link below."
46
+ action: "Change my password"
47
+ ignore: "If you didn't request this, please ignore this email."
48
+ expiration: "This link will expire in %{hours} hour(s)."
@@ -0,0 +1,48 @@
1
+ it:
2
+ better_authy:
3
+ sessions:
4
+ title: "Accedi"
5
+ submit: "Accedi"
6
+ remember_me: "Ricordami"
7
+ signed_in: "Accesso effettuato."
8
+ signed_out: "Disconnessione effettuata."
9
+ invalid_credentials: "Email o password non validi."
10
+ unauthenticated: "Devi effettuare l'accesso."
11
+ no_account: "Non hai un account?"
12
+ signup_link: "Registrati"
13
+ forgot_password: "Password dimenticata?"
14
+ registrations:
15
+ title: "Registrati"
16
+ submit: "Crea account"
17
+ errors_title: "Correggi i seguenti errori:"
18
+ signed_up: "Registrazione completata."
19
+ has_account: "Hai già un account?"
20
+ login_link: "Accedi"
21
+ attributes:
22
+ email: "Email"
23
+ password: "Password"
24
+ password_confirmation: "Conferma password"
25
+ placeholders:
26
+ email: "tu@esempio.com"
27
+ password: "Inserisci la password"
28
+ password_confirmation: "Conferma la password"
29
+ hints:
30
+ password: "Minimo 8 caratteri"
31
+ passwords:
32
+ forgot_title: "Password dimenticata"
33
+ forgot_instruction: "Inserisci il tuo indirizzo email e ti invieremo le istruzioni per reimpostare la password."
34
+ forgot_submit: "Invia istruzioni"
35
+ remembered: "Ricordi la password?"
36
+ reset_title: "Reimposta password"
37
+ reset_submit: "Reimposta password"
38
+ send_instructions: "Se il tuo indirizzo email esiste nel nostro database, riceverai le istruzioni per reimpostare la password a breve."
39
+ updated: "La tua password e' stata modificata con successo. Ora puoi accedere."
40
+ invalid_token: "Il link per reimpostare la password non e' valido o e' scaduto."
41
+ no_token: "Token per reimpostare la password non fornito."
42
+ mailer:
43
+ subject: "Istruzioni per reimpostare la password"
44
+ greeting: "Ciao %{email}!"
45
+ instruction: "Qualcuno ha richiesto un link per cambiare la tua password. Puoi farlo tramite il link qui sotto."
46
+ action: "Cambia la mia password"
47
+ ignore: "Se non hai richiesto tu questa operazione, ignora questa email."
48
+ expiration: "Questo link scadra' tra %{hours} ora/e."
data/config/routes.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ BetterAuthy::Engine.routes.draw do
4
+ # Generate routes for each configured scope
5
+ # The defaults: { scope: scope_name } injects the scope into params
6
+ # so controllers don't need to parse the URL path
7
+ BetterAuthy.configuration.scopes.each_key do |scope_name|
8
+ scope scope_name.to_s, defaults: { scope: scope_name } do
9
+ get "login", to: "sessions#new", as: :"#{scope_name}_login"
10
+ post "login", to: "sessions#create"
11
+ delete "logout", to: "sessions#destroy", as: :"#{scope_name}_logout"
12
+
13
+ get "signup", to: "registrations#new", as: :"#{scope_name}_signup"
14
+ post "signup", to: "registrations#create"
15
+
16
+ # Password reset routes
17
+ get "password/new", to: "passwords#new", as: :"new_#{scope_name}_password"
18
+ post "password", to: "passwords#create", as: :"#{scope_name}_password"
19
+ get "password/edit", to: "passwords#edit", as: :"edit_#{scope_name}_password"
20
+ patch "password", to: "passwords#update"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ class Configuration
5
+ attr_reader :scopes, :cookie_config
6
+
7
+ def initialize
8
+ @scopes = {}
9
+ @cookie_config = {
10
+ secure: ENV.fetch("BETTER_AUTHY_SECURE_COOKIES", "false") == "true",
11
+ httponly: true,
12
+ same_site: :lax
13
+ }
14
+ end
15
+
16
+ def cookie_config=(options)
17
+ @cookie_config = @cookie_config.merge(options)
18
+ end
19
+
20
+ def scope(name, &block)
21
+ raise ArgumentError, "scope requires a block" unless block_given?
22
+
23
+ name = name.to_sym
24
+ raise ConfigurationError, "Scope :#{name} is already registered" if @scopes.key?(name)
25
+
26
+ scope_config = ScopeConfiguration.new(name)
27
+ yield(scope_config)
28
+ @scopes[name] = scope_config
29
+ end
30
+
31
+ def scope_for(name)
32
+ @scopes[name.to_sym]
33
+ end
34
+
35
+ def scope_for!(name)
36
+ scope_for(name) || raise(ConfigurationError, "Scope :#{name} is not registered")
37
+ end
38
+
39
+ def validate!
40
+ @scopes.each_value(&:validate!)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ module ControllerHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Define helper methods for each configured scope
9
+ BetterAuthy.configuration.scopes.each_key do |scope_name|
10
+ define_scope_helpers(scope_name)
11
+ end
12
+ end
13
+
14
+ class_methods do
15
+ def define_scope_helpers(scope_name)
16
+ # current_{scope}
17
+ define_method(:"current_#{scope_name}") do
18
+ ivar = "@current_#{scope_name}"
19
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
20
+
21
+ instance_variable_set(ivar, find_authenticated_resource(scope_name))
22
+ end
23
+
24
+ # {scope}_signed_in?
25
+ define_method(:"#{scope_name}_signed_in?") do
26
+ send(:"current_#{scope_name}").present?
27
+ end
28
+
29
+ # sign_in_{scope}
30
+ define_method(:"sign_in_#{scope_name}") do |resource, remember: false|
31
+ scope_config = BetterAuthy.scope_for!(scope_name)
32
+
33
+ reset_session
34
+ session[scope_config.session_key] = resource.id
35
+ resource.track_sign_in!(request)
36
+
37
+ if remember
38
+ token = resource.remember_me!
39
+ cookie_value = "#{resource.id}:#{token}"
40
+ cookies.encrypted[scope_config.remember_cookie] = {
41
+ value: cookie_value,
42
+ expires: scope_config.remember_for.from_now,
43
+ **BetterAuthy.configuration.cookie_config
44
+ }
45
+ end
46
+
47
+ instance_variable_set("@current_#{scope_name}", resource)
48
+ end
49
+
50
+ # sign_out_{scope}
51
+ define_method(:"sign_out_#{scope_name}") do
52
+ scope_config = BetterAuthy.scope_for!(scope_name)
53
+ current_resource = send(:"current_#{scope_name}")
54
+
55
+ current_resource&.forget_me!
56
+
57
+ session.delete(scope_config.session_key)
58
+ cookies.delete(scope_config.remember_cookie)
59
+
60
+ instance_variable_set("@current_#{scope_name}", nil)
61
+ end
62
+
63
+ # authenticate_{scope}!
64
+ define_method(:"authenticate_#{scope_name}!") do
65
+ return if send(:"#{scope_name}_signed_in?")
66
+
67
+ scope_config = BetterAuthy.scope_for!(scope_name)
68
+ redirect_to(scope_config.sign_in_path)
69
+ end
70
+
71
+ # Register as helper methods
72
+ helper_method :"current_#{scope_name}", :"#{scope_name}_signed_in?"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def find_authenticated_resource(scope_name)
79
+ scope_config = BetterAuthy.scope_for!(scope_name)
80
+ model_class = scope_config.model_class
81
+
82
+ # Try session first
83
+ if (resource_id = session[scope_config.session_key])
84
+ return model_class.find_by(id: resource_id)
85
+ end
86
+
87
+ # Try remember cookie
88
+ if (cookie_value = cookies.encrypted[scope_config.remember_cookie])
89
+ resource_id, token = cookie_value.split(":", 2)
90
+ resource = model_class.find_by(id: resource_id)
91
+ return resource if resource&.remember_token_valid?(token)
92
+ end
93
+
94
+ nil
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace BetterAuthy
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ module ModelExtensions
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def better_authy_authenticable(scope_name, **options)
9
+ # Store scope name and options on the class
10
+ class_attribute :authenticable_scope_name, default: scope_name
11
+ class_attribute :authenticable_options, default: options
12
+
13
+ # Include the authenticable concern (runs included do block)
14
+ include BetterAuthy::Models::Authenticable
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Extend ActiveRecord::Base when ActiveRecord is loaded
21
+ ActiveSupport.on_load(:active_record) do
22
+ include BetterAuthy::ModelExtensions
23
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ module Models
5
+ module Authenticable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Configure has_secure_password
10
+ has_secure_password
11
+
12
+ # Email validations
13
+ validates :email,
14
+ presence: true,
15
+ uniqueness: { case_sensitive: false },
16
+ format: { with: URI::MailTo::EMAIL_REGEXP }
17
+
18
+ # Password validation (uses options from better_authy_authenticable call)
19
+ validates :password,
20
+ length: { minimum: authenticable_options.fetch(:password_minimum, 8) },
21
+ allow_nil: true
22
+
23
+ # Email normalization
24
+ normalizes :email, with: ->(email) { email.strip.downcase }
25
+ end
26
+
27
+ # Returns the scope name for this model
28
+ def authenticable_scope
29
+ self.class.authenticable_scope_name
30
+ end
31
+
32
+ # Returns the scope configuration
33
+ def authenticable_scope_config
34
+ BetterAuthy.scope_for(authenticable_scope)
35
+ end
36
+
37
+ # Generate remember token
38
+ def remember_me!
39
+ token = SecureRandom.urlsafe_base64(32)
40
+ update!(
41
+ remember_token_digest: BCrypt::Password.create(token),
42
+ remember_created_at: Time.current
43
+ )
44
+ token
45
+ end
46
+
47
+ # Clear remember token
48
+ def forget_me!
49
+ update!(
50
+ remember_token_digest: nil,
51
+ remember_created_at: nil
52
+ )
53
+ end
54
+
55
+ # Validate remember token
56
+ def remember_token_valid?(token)
57
+ return false if remember_token_digest.blank?
58
+ return false if remember_created_at.blank?
59
+ return false if remember_created_at < authenticable_scope_config.remember_for.ago
60
+
61
+ BCrypt::Password.new(remember_token_digest).is_password?(token)
62
+ end
63
+
64
+ # Track sign in
65
+ def track_sign_in!(request)
66
+ now = Time.current
67
+ update!(
68
+ sign_in_count: sign_in_count + 1,
69
+ last_sign_in_at: current_sign_in_at,
70
+ last_sign_in_ip: current_sign_in_ip,
71
+ current_sign_in_at: now,
72
+ current_sign_in_ip: request.remote_ip
73
+ )
74
+ end
75
+
76
+ # Generate password reset token
77
+ def generate_password_reset_token!
78
+ token = SecureRandom.urlsafe_base64(32)
79
+ update!(
80
+ password_reset_token_digest: BCrypt::Password.create(token),
81
+ password_reset_sent_at: Time.current
82
+ )
83
+ token
84
+ end
85
+
86
+ # Validate password reset token
87
+ def password_reset_token_valid?(token)
88
+ return false if password_reset_token_digest.blank?
89
+ return false if password_reset_sent_at.blank?
90
+ return false if password_reset_sent_at < authenticable_scope_config.password_reset_within.ago
91
+
92
+ BCrypt::Password.new(password_reset_token_digest).is_password?(token)
93
+ end
94
+
95
+ # Clear password reset token
96
+ def clear_password_reset_token!
97
+ update!(
98
+ password_reset_token_digest: nil,
99
+ password_reset_sent_at: nil
100
+ )
101
+ end
102
+
103
+ # Reset password with confirmation
104
+ def reset_password!(new_password, new_password_confirmation)
105
+ self.password = new_password
106
+ self.password_confirmation = new_password_confirmation
107
+
108
+ if valid?
109
+ save!
110
+ clear_password_reset_token!
111
+ true
112
+ else
113
+ false
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ class ScopeConfiguration
5
+ attr_reader :name
6
+ attr_accessor :model_name, :session_key, :remember_cookie, :remember_for,
7
+ :sign_in_path, :after_sign_in_path, :layout, :password_reset_within
8
+
9
+ def initialize(name)
10
+ @name = name.to_sym
11
+ @session_key = :"#{name}_id"
12
+ @remember_cookie = :"_remember_#{name}_token"
13
+ @remember_for = 2.weeks
14
+ @password_reset_within = 1.hour
15
+ @sign_in_path = "/auth/#{name}/login"
16
+ @after_sign_in_path = "/"
17
+ @layout = "better_authy/application"
18
+ end
19
+
20
+ def model_class
21
+ raise ConfigurationError, "model_name is required for scope :#{name}" if model_name.blank?
22
+
23
+ model_name.constantize
24
+ end
25
+
26
+ def validate!
27
+ raise ConfigurationError, "model_name is required for scope :#{name}" if model_name.blank?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuthy
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "better_ui"
5
+
6
+ require "better_authy/version"
7
+ require "better_authy/errors"
8
+ require "better_authy/scope_configuration"
9
+ require "better_authy/configuration"
10
+ require "better_authy/engine"
11
+ require "better_authy/models/authenticable"
12
+ require "better_authy/model_extensions"
13
+ require "better_authy/controller_helpers"
14
+
15
+ module BetterAuthy
16
+ class << self
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def reset_configuration!
26
+ @configuration = Configuration.new
27
+ end
28
+
29
+ def scope_for(name)
30
+ configuration.scope_for(name)
31
+ end
32
+
33
+ def scope_for!(name)
34
+ configuration.scope_for!(name)
35
+ end
36
+ end
37
+ end