diff --git a/Gemfile b/Gemfile
index 4e62fa53..557a5866 100644
--- a/Gemfile
+++ b/Gemfile
@@ -145,3 +145,9 @@ end
gem "cssbundling-rails", "~> 1.4"
gem "tailwindcss-rails", "~> 3.0"
+
+# Authentication
+gem "devise", "~> 4.9"
+
+# Authorization
+gem "pundit", "~> 2.4"
diff --git a/Gemfile.lock b/Gemfile.lock
index e6035183..9388f3b6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -104,6 +104,7 @@ GEM
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
base64 (0.2.0)
+ bcrypt (3.1.20)
benchmark (0.4.0)
bigdecimal (3.1.8)
bindex (0.8.1)
@@ -139,6 +140,12 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
debug_inspector (1.1.0)
+ devise (4.9.4)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0)
+ responders
+ warden (~> 1.2.3)
diff-lcs (1.5.1)
diffy (3.4.2)
discard (1.2.1)
@@ -237,6 +244,7 @@ GEM
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
numo-narray (0.9.2.1)
+ orm_adapter (0.5.0)
parallel (1.26.3)
parallel_tests (4.7.2)
parallel
@@ -260,6 +268,8 @@ GEM
public_suffix (5.0.1)
puma (5.6.5)
nio4r (~> 2.0)
+ pundit (2.4.0)
+ activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
@@ -329,6 +339,9 @@ GEM
regexp_parser (2.6.2)
reline (0.6.0)
io-console (~> 0.5)
+ responders (3.1.1)
+ actionpack (>= 5.2)
+ railties (>= 5.2)
rexml (3.2.5)
rice (4.0.4)
ripcord (2.0.0)
@@ -407,6 +420,8 @@ GEM
unf_ext
unf_ext (0.0.8.2)
useragent (0.16.11)
+ warden (1.2.9)
+ rack (>= 2.0.9)
web-console (4.2.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
@@ -440,6 +455,7 @@ DEPENDENCIES
curb
daemons
debug (~> 1.10)
+ devise (~> 4.9)
diffy
discard
disco
@@ -463,6 +479,7 @@ DEPENDENCIES
pry
pry-stack_explorer
puma (~> 5.0)
+ pundit (~> 2.4)
rack-cors
rack-mini-profiler (~> 3.3)
rails (~> 7.2)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 249fd95e..564f61f9 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,5 +1,7 @@
class ApplicationController < ActionController::Base
- before_action :validate_api_token
+ include Pundit::Authorization
+
+ before_action :authenticate_user_or_validate_api_token
before_action do
if Rails.env.development? || Rails.env.staging?
Rack::MiniProfiler.authorize_request
@@ -15,12 +17,25 @@ class ApplicationController < ActionController::Base
"9b3cf444-5913-4efb-9935-bf26501232ff" => "syfaro",
}
- def validate_api_token
+ protected
+
+ def authenticate_user_or_validate_api_token
api_token = request.params[:api_token]
- user = API_TOKENS[api_token]
- if user.nil?
+ api_user_name = API_TOKENS[api_token]
+
+ if api_user_name.nil?
return if VpnOnlyRouteConstraint.new.matches?(request)
render status: 403, json: { error: "not authenticated" }
end
end
+
+ # Pundit authorization error handling
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
+
+ private
+
+ def user_not_authorized
+ flash[:alert] = "You are not authorized to perform this action."
+ redirect_back(fallback_location: root_path)
+ end
end
diff --git a/app/controllers/domain/fa/users_controller.rb b/app/controllers/domain/fa/users_controller.rb
index 5540f4c6..9abc1519 100644
--- a/app/controllers/domain/fa/users_controller.rb
+++ b/app/controllers/domain/fa/users_controller.rb
@@ -1,14 +1,19 @@
class Domain::Fa::UsersController < ApplicationController
before_action :set_user, only: %i[show]
- skip_before_action :validate_api_token, only: %i[show]
+ skip_before_action :authenticate_user_or_validate_api_token, only: %i[show]
# GET /domain/fa/users or /domain/fa/users.json
def index
- @users = Domain::Fa::User.includes({ avatar: [:file] }).page(params[:page])
+ authorize Domain::Fa::User
+ @users =
+ policy_scope(Domain::Fa::User).includes({ avatar: [:file] }).page(
+ params[:page],
+ )
end
# GET /domain/fa/users/1 or /domain/fa/users/1.json
def show
+ authorize @user
end
private
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 13d748b5..5b37f0d0 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -1,5 +1,5 @@
class PagesController < ApplicationController
- skip_before_action :validate_api_token, only: [:root]
+ skip_before_action :authenticate_user_or_validate_api_token, only: [:root]
def root
render :root
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 00000000..5eab1bb3
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,16 @@
+class User < ReduxApplicationRecord
+ # Include default devise modules. Others available are:
+ # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
+ devise :database_authenticatable,
+ :registerable,
+ :recoverable,
+ :rememberable,
+ :validatable
+
+ enum role: {
+ user: "user",
+ admin: "admin",
+ moderator: "moderator",
+ },
+ _default: "user"
+end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100644
index 00000000..be644fe3
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class ApplicationPolicy
+ attr_reader :user, :record
+
+ def initialize(user, record)
+ @user = user
+ @record = record
+ end
+
+ def index?
+ false
+ end
+
+ def show?
+ false
+ end
+
+ def create?
+ false
+ end
+
+ def new?
+ create?
+ end
+
+ def update?
+ false
+ end
+
+ def edit?
+ update?
+ end
+
+ def destroy?
+ false
+ end
+
+ class Scope
+ def initialize(user, scope)
+ @user = user
+ @scope = scope
+ end
+
+ def resolve
+ raise NoMethodError, "You must define #resolve in #{self.class}"
+ end
+
+ private
+
+ attr_reader :user, :scope
+ end
+end
diff --git a/app/policies/domain/fa/user_policy.rb b/app/policies/domain/fa/user_policy.rb
new file mode 100644
index 00000000..a439c502
--- /dev/null
+++ b/app/policies/domain/fa/user_policy.rb
@@ -0,0 +1,24 @@
+class Domain::Fa::UserPolicy < ApplicationPolicy
+ def index?
+ true # Anyone can view the index
+ end
+
+ def show?
+ true # Anyone can view user profiles
+ end
+
+ # Only admins and moderators can access these actions
+ def scan_user?
+ user&.admin? || user&.moderator?
+ end
+
+ def enqueue_objects?
+ user&.admin? || user&.moderator?
+ end
+
+ class Scope < Scope
+ def resolve
+ scope.all
+ end
+ end
+end
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb
new file mode 100644
index 00000000..b12dd0cb
--- /dev/null
+++ b/app/views/devise/confirmations/new.html.erb
@@ -0,0 +1,16 @@
+
Resend confirmation instructions
+
+<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
+
+
+
+ <%= f.submit "Resend confirmation instructions" %>
+
+<% end %>
+
+<%= render "devise/shared/links" %>
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
new file mode 100644
index 00000000..dc55f64f
--- /dev/null
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -0,0 +1,5 @@
+Welcome <%= @email %>!
+
+You can confirm your account email through the link below:
+
+<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb
new file mode 100644
index 00000000..32f4ba80
--- /dev/null
+++ b/app/views/devise/mailer/email_changed.html.erb
@@ -0,0 +1,7 @@
+Hello <%= @email %>!
+
+<% if @resource.try(:unconfirmed_email?) %>
+ We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.
+<% else %>
+ We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %>
diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb
new file mode 100644
index 00000000..b41daf47
--- /dev/null
+++ b/app/views/devise/mailer/password_change.html.erb
@@ -0,0 +1,3 @@
+Hello <%= @resource.email %>!
+
+We're contacting you to notify you that your password has been changed.
diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb
new file mode 100644
index 00000000..f667dc12
--- /dev/null
+++ b/app/views/devise/mailer/reset_password_instructions.html.erb
@@ -0,0 +1,8 @@
+Hello <%= @resource.email %>!
+
+Someone has requested a link to change your password. You can do this through the link below.
+
+<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>
+
+If you didn't request this, please ignore this email.
+Your password won't change until you access the link above and create a new one.
diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb
new file mode 100644
index 00000000..41e148bf
--- /dev/null
+++ b/app/views/devise/mailer/unlock_instructions.html.erb
@@ -0,0 +1,7 @@
+Hello <%= @resource.email %>!
+
+Your account has been locked due to an excessive number of unsuccessful sign in attempts.
+
+Click the link below to unlock your account:
+
+<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb
new file mode 100644
index 00000000..5fbb9ff0
--- /dev/null
+++ b/app/views/devise/passwords/edit.html.erb
@@ -0,0 +1,25 @@
+Change your password
+
+<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+ <%= f.hidden_field :reset_password_token %>
+
+
+ <%= f.label :password, "New password" %>
+ <% if @minimum_password_length %>
+ (<%= @minimum_password_length %> characters minimum)
+ <% end %>
+ <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>
+
+
+
+ <%= f.label :password_confirmation, "Confirm new password" %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
+
+
+
+ <%= f.submit "Change my password" %>
+
+<% end %>
+
+<%= render "devise/shared/links" %>
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb
new file mode 100644
index 00000000..a376cdeb
--- /dev/null
+++ b/app/views/devise/passwords/new.html.erb
@@ -0,0 +1,34 @@
+
+
Forgot your password?
+
+ <%= form_for(
+ resource,
+ as: resource_name,
+ url: password_path(resource_name),
+ html: {
+ method: :post,
+ class: "space-y-4",
+ },
+ ) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-slate-700" %>
+ <%= f.email_field :email,
+ autofocus: true,
+ autocomplete: "email",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.submit "Send reset password instructions",
+ class:
+ "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-slate-600 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500" %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
new file mode 100644
index 00000000..290380ab
--- /dev/null
+++ b/app/views/devise/registrations/edit.html.erb
@@ -0,0 +1,91 @@
+
+
Edit Profile
+
+ <%= form_for(
+ resource,
+ as: resource_name,
+ url: registration_path(resource_name),
+ html: {
+ method: :put,
+ class: "space-y-4",
+ },
+ ) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-slate-700" %>
+ <%= f.email_field :email,
+ autofocus: true,
+ autocomplete: "email",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+ <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
+
+ Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+
+ <% end %>
+
+
+ <%= f.label :password, class: "block text-sm font-medium text-slate-700" %>
+
+ (leave blank if you don't want to change it)
+
+ <%= f.password_field :password,
+ autocomplete: "new-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+ <% if @minimum_password_length %>
+
+ <%= @minimum_password_length %> characters minimum
+
+ <% end %>
+
+
+
+ <%= f.label :password_confirmation,
+ class: "block text-sm font-medium text-slate-700" %>
+ <%= f.password_field :password_confirmation,
+ autocomplete: "new-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.label :current_password, class: "block text-sm font-medium text-slate-700" %>
+
+ (we need your current password to confirm your changes)
+
+ <%= f.password_field :current_password,
+ autocomplete: "current-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.submit "Update",
+ class:
+ "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-slate-600 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500" %>
+
+ <% end %>
+
+
+
Cancel my account
+
+
Unhappy?
+ <%= button_to "Cancel my account",
+ registration_path(resource_name),
+ data: {
+ confirm: "Are you sure?",
+ turbo_confirm: "Are you sure?",
+ },
+ method: :delete,
+ class:
+ "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+
+
+
+ <%= link_to "Back", :back, class: "text-slate-600 hover:text-slate-900" %>
+
+
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
new file mode 100644
index 00000000..09f925b2
--- /dev/null
+++ b/app/views/devise/registrations/new.html.erb
@@ -0,0 +1,55 @@
+
+
Sign Up
+
+ <%= form_for(
+ resource,
+ as: resource_name,
+ url: registration_path(resource_name),
+ html: {
+ class: "space-y-4",
+ },
+ ) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-slate-700" %>
+ <%= f.email_field :email,
+ autofocus: true,
+ autocomplete: "email",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.label :password, class: "block text-sm font-medium text-slate-700" %>
+ <% if @minimum_password_length %>
+ (<%= @minimum_password_length %> characters minimum)
+ <% end %>
+ <%= f.password_field :password,
+ autocomplete: "new-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.label :password_confirmation,
+ class: "block text-sm font-medium text-slate-700" %>
+ <%= f.password_field :password_confirmation,
+ autocomplete: "new-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.submit "Sign up",
+ class:
+ "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-slate-600 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500" %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
new file mode 100644
index 00000000..9cb7e778
--- /dev/null
+++ b/app/views/devise/sessions/new.html.erb
@@ -0,0 +1,48 @@
+
+
Sign In
+
+ <%= form_for(
+ resource,
+ as: resource_name,
+ url: session_path(resource_name),
+ html: {
+ class: "space-y-4",
+ },
+ ) do |f| %>
+
+ <%= f.label :email, class: "block text-sm font-medium text-slate-700" %>
+ <%= f.email_field :email,
+ autofocus: true,
+ autocomplete: "email",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+
+ <%= f.label :password, class: "block text-sm font-medium text-slate-700" %>
+ <%= f.password_field :password,
+ autocomplete: "current-password",
+ class:
+ "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500" %>
+
+
+ <% if devise_mapping.rememberable? %>
+
+ <%= f.check_box :remember_me,
+ class:
+ "h-4 w-4 rounded border-slate-300 text-slate-600 focus:ring-slate-500" %>
+ <%= f.label :remember_me, class: "ml-2 block text-sm text-slate-700" %>
+
+ <% end %>
+
+
+ <%= f.submit "Sign in",
+ class:
+ "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-slate-600 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500" %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb
new file mode 100644
index 00000000..9bc0980d
--- /dev/null
+++ b/app/views/devise/shared/_error_messages.html.erb
@@ -0,0 +1,37 @@
+<% if resource.errors.any? %>
+
+
+
+
+
+ <%= I18n.t(
+ "errors.messages.not_saved",
+ count: resource.errors.count,
+ resource: resource.class.model_name.human.downcase,
+ ) %>
+
+
+
+ <% resource.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+
+
+
+<% end %>
diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb
new file mode 100644
index 00000000..f36a863f
--- /dev/null
+++ b/app/views/devise/shared/_links.html.erb
@@ -0,0 +1,40 @@
+
+ <%- if controller_name != "sessions" %>
+ <%= link_to "Sign in",
+ new_session_path(resource_name),
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <%- if devise_mapping.registerable? && controller_name != "registrations" %>
+ <%= link_to "Sign up",
+ new_registration_path(resource_name),
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <%- if devise_mapping.recoverable? && controller_name != "passwords" &&
+ controller_name != "registrations" %>
+ <%= link_to "Forgot your password?",
+ new_password_path(resource_name),
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <%- if devise_mapping.confirmable? && controller_name != "confirmations" %>
+ <%= link_to "Didn't receive confirmation instructions?",
+ new_confirmation_path(resource_name),
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <%- if devise_mapping.lockable? &&
+ resource_class.unlock_strategy_enabled?(:email) &&
+ controller_name != "unlocks" %>
+ <%= link_to "Didn't receive unlock instructions?",
+ new_unlock_path(resource_name),
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <%- if devise_mapping.omniauthable? %>
+ <%- resource_class.omniauth_providers.each do |provider| %>
+ <%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}",
+ omniauth_authorize_path(resource_name, provider),
+ data: {
+ turbo: false,
+ },
+ class: "text-slate-600 hover:text-slate-900" %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb
new file mode 100644
index 00000000..ffc34de8
--- /dev/null
+++ b/app/views/devise/unlocks/new.html.erb
@@ -0,0 +1,16 @@
+Resend unlock instructions
+
+<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
+
+
+
+ <%= f.submit "Resend unlock instructions" %>
+
+<% end %>
+
+<%= render "devise/shared/links" %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index f821ddd1..1e7f9cea 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -26,12 +26,35 @@
<%= link_to "ReFurrer", root_path %>
-
+
+
Furry Swiss Army Knife
+ <% if notice %>
+ <%= notice %>
+ <% end %>
+ <% if alert %>
+ <%= alert %>
+ <% end %>
<%= yield %>