Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ gem 'lingua'
# This is a really small gem that can be used to retrieve objects from the database in the order of the list given
gem 'find_with_order'

gem 'rack-attack'

group :development, :test do
gem 'debug', platforms: %i[mri mingw x64_mingw]
gem 'factory_bot_rails'
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ GEM
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-session (1.0.2)
Expand Down Expand Up @@ -380,6 +382,7 @@ DEPENDENCIES
lingua
mysql2 (~> 0.5.5)
puma (~> 5.0)
rack-attack
rack-cors
rails (~> 8.0, >= 8.0.1)
rspec-rails
Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v1/institutions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Api::V1::InstitutionsController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :institution_not_found
skip_before_action :authorize, only: [:index]
def action_allowed?
has_role?('Instructor')
end
Expand Down
96 changes: 94 additions & 2 deletions app/controllers/api/v1/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ def index
end

# GET /users/:id

def show
user = User.find(params[:id])
render json: user, status: :ok
render json: user.as_json(except: [:password_digest]), status: :ok
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end

# POST /users
Expand Down Expand Up @@ -76,15 +79,104 @@ def role_users
render json: { error: e.message }, status: :not_found
end

# GET /api/v1/users/:id/get_profile : Returns basic user profile information and email preferences
def get_profile
user = User.includes(:institution).find(params[:id])

render json: {
id: user.id,
full_name: user.full_name,
email: user.email,
handle: user.handle || '',
can_show_actions: user.can_show_actions,
time_zone: user.time_zone || 'GMT-05:00',
language: user.language || 'No Preference',
email_on_review: user.email_on_review.nil? ? true : user.email_on_review,
email_on_submission: user.email_on_submission.nil? ? true : user.email_on_submission,
email_on_review_of_review: user.email_on_review_of_review.nil? ? true : user.email_on_review_of_review,
institution: {
id: user.institution&.id || 0,
name: user.institution&.name || 'Other'
}
}, status: :ok
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end

# PATCH /users/:id
def update_profile
user = User.find(params[:id])

if user.update(user_params)
render json: {
message: 'Profile updated successfully.',
user: user.as_json(except: [:password_digest])
}, status: :ok
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end

# POST /users/:id/update_password
def update_password
user = User.find(params[:id])

password = params[:password]
confirm_password = params[:confirmPassword]

if password.blank? || confirm_password.blank?
return render json: { error: 'Both password and confirmPassword are required' }, status: :bad_request
end

if password != confirm_password
return render json: { error: 'Passwords do not match' }, status: :unprocessable_entity
end

if user.update(password: password, password_confirmation: confirm_password)
# update jwt_version and issue new token
user.update(jwt_version: SecureRandom.uuid)

payload = {
id: user.id,
name: user.name,
full_name: user.full_name,
role: user.role.name,
institution_id: user.institution.id,
jwt_version: user.jwt_version
}

new_token = JsonWebToken.encode(payload, 24.hours.from_now)

render json: {
message: 'Password updated successfully',
token: new_token,
user: user.as_json(except: [:password_digest])
}, status: :ok
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end


private

# Only allow a list of trusted parameters through.
def user_params
params.require(:user).permit(:id, :name, :role_id, :full_name, :email, :parent_id, :institution_id,
:email_on_review, :email_on_submission, :email_on_review_of_review,
:handle, :copy_of_emails, :password, :password_confirmation)
:handle, :copy_of_emails, :password, :password_confirmation,
:time_zone, :language, :can_show_actions)
end

# Allowed params for profile update only
def user_profile_params
params.require(:user).permit(:email, :full_name, :email_on_review,
:email_on_submission, :email_on_review_of_review,
:time_zone, :language, :can_show_actions)
end

def user_not_found
render json: { error: "User with id #{params[:id]} not found" }, status: :not_found
end
Expand Down
67 changes: 64 additions & 3 deletions app/controllers/authentication_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,78 @@

class AuthenticationController < ApplicationController
skip_before_action :authenticate_request!

# Simple in-memory rate limiting
LOGIN_ATTEMPTS = {}
MAX_ATTEMPTS = 5
BLOCK_DURATION = 20.seconds

# POST /login
def login
# Add a small random delay to prevent timing attacks
sleep(rand(0.1..0.3))

# Check rate limiting
ip = request.remote_ip
username = params[:user_name].to_s.downcase

# Skip rate limiting for localhost
is_localhost = ip == '127.0.0.1' || ip == '::1'

unless is_localhost
# Check if IP is blocked
if LOGIN_ATTEMPTS[ip] && LOGIN_ATTEMPTS[ip][:count] >= MAX_ATTEMPTS &&
Time.now - LOGIN_ATTEMPTS[ip][:timestamp] < BLOCK_DURATION
render json: { error: 'Rate limit exceeded. Try again later.' }, status: :too_many_requests
return
end

# Check if username is blocked
if LOGIN_ATTEMPTS[username] && LOGIN_ATTEMPTS[username][:count] >= MAX_ATTEMPTS &&
Time.now - LOGIN_ATTEMPTS[username][:timestamp] < BLOCK_DURATION
render json: { error: 'Rate limit exceeded. Try again later.' }, status: :too_many_requests
return
end
end

user = User.find_by(name: params[:user_name]) || User.find_by(email: params[:user_name])

if user&.authenticate(params[:password])
payload = { id: user.id, name: user.name, full_name: user.full_name, role: user.role.name,
institution_id: user.institution.id }
# Reset counters on successful login
LOGIN_ATTEMPTS[ip] = { count: 0, timestamp: Time.now } if LOGIN_ATTEMPTS[ip]
LOGIN_ATTEMPTS[username] = { count: 0, timestamp: Time.now } if LOGIN_ATTEMPTS[username]

payload = {
id: user.id,
name: user.name,
full_name: user.full_name,
role: user.role.name,
institution_id: user.institution.id,
jwt_version: user.jwt_version
}
token = JsonWebToken.encode(payload, 24.hours.from_now)
render json: { token: }, status: :ok
else
render json: { error: 'Invalid username / password' }, status: :unauthorized
# Only increment counters for non-localhost requests
unless is_localhost
# Increment counters on failed login
LOGIN_ATTEMPTS[ip] ||= { count: 0, timestamp: Time.now }
LOGIN_ATTEMPTS[ip][:count] += 1
LOGIN_ATTEMPTS[ip][:timestamp] = Time.now

LOGIN_ATTEMPTS[username] ||= { count: 0, timestamp: Time.now }
LOGIN_ATTEMPTS[username][:count] += 1
LOGIN_ATTEMPTS[username][:timestamp] = Time.now

# Check if we've exceeded the rate limit after this failed attempt
if LOGIN_ATTEMPTS[ip][:count] > MAX_ATTEMPTS || LOGIN_ATTEMPTS[username][:count] > MAX_ATTEMPTS
render json: { error: 'Rate limit exceeded. Try again later.' }, status: :too_many_requests
return
end
end

# Use a generic error message that doesn't reveal whether the username exists
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/concerns/jwt_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ def authenticate_request!
return
end
@current_user = User.find(auth_token[:id])

# Invalidate token if jwt_version no longer matches
if auth_token[:jwt_version] != @current_user.jwt_version
render json: { error: 'Token has been invalidated. Please login again.' }, status: :unauthorized
return
end


rescue JWT::VerificationError, JWT::DecodeError
render json: { error: 'Not Authorized' }, status: :unauthorized
end
Expand Down
2 changes: 2 additions & 0 deletions app/models/role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def subordinate_roles_and_self

# checks if the current role has all the privileges of the target role
def all_privileges_of?(target_role)
return false if target_role.nil? || target_role.name.nil?

privileges = {
'Student' => 1,
'Teaching Assistant' => 2,
Expand Down
14 changes: 13 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ class User < ApplicationRecord
validates :name, presence: true, uniqueness: true, allow_blank: false
# format: { with: /\A[a-z]+\z/, message: 'must be in lowercase' }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, presence: true, allow_nil: true

# password length min 8
validates :password, length: { minimum: 8 }, presence: true, allow_nil: true

# profile fields for editing
validates :full_name, presence: true, length: { maximum: 50 }

before_create :set_jwt_version

belongs_to :role
belongs_to :institution, optional: true
belongs_to :parent, class_name: 'User', optional: true
Expand Down Expand Up @@ -114,4 +120,10 @@ def set_defaults
self.etc_icons_on_homepage ||= true
end

private

def set_jwt_version
self.jwt_version ||= SecureRandom.uuid
end

end
3 changes: 3 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ class Application < Rails::Application
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
config.cache_store = :redis_store, ENV['CACHE_STORE'], { expires_in: 3.days, raise_errors: false }

# Enable rack-attack
config.middleware.use Rack::Attack
end
end
5 changes: 2 additions & 3 deletions config/database.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ default: &default
username: dev
password: Root@123


development:
<<: *default
database: reimplementation_development
Expand All @@ -26,7 +25,7 @@ development:
# Do not set this db to the same as development or production.
test:
<<: *default
database: reimplementation_test
database: reimplementation_development

# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
Expand All @@ -52,4 +51,4 @@ production:
<<: *default
database: reimplementation_production
username: reimplementation
password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %>
password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %>
56 changes: 56 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
class Rack::Attack
# Use memory store for rate limiting
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

# Allow all requests from localhost
safelist('allow-localhost') do |req|
'127.0.0.1' == req.ip || '::1' == req.ip
end

# Throttle all requests by IP (60 requests per minute)
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
throttle('req/ip', limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?('/assets')
end

# Throttle login attempts by IP address
# Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}"
throttle('logins/ip', limit: 5, period: 20.seconds) do |req|
if req.path == '/login' && req.post?
req.ip
end
end

# Throttle login attempts by user name
# Key: "rack::attack:#{Time.now.to_i/:period}:logins:#{req.params['user_name']}"
throttle('logins/username', limit: 5, period: 20.seconds) do |req|
if req.path == '/login' && req.post?
req.params['user_name'].to_s.downcase
end
end

# Block suspicious requests
blocklist('block suspicious requests') do |req|
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 3, findtime: 10.minutes, bantime: 1.hour) do
req.path == '/login' && req.post? && req.params['user_name'].present?
end
end

# Return rate limit info in response headers
Rack::Attack.throttled_responder = lambda do |env|
match_data = env['rack.attack.match_data']
if match_data[:request].path == '/login'
[
429, # status
{ 'Content-Type' => 'application/json' }, # headers
[{ error: 'Rate limit exceeded. Try again later.' }.to_json] # body
]
else
[
429, # status
{ 'Content-Type' => 'application/json' }, # headers
[{ error: 'Rate limit exceeded. Try again later.' }.to_json] # body
]
end
end
end
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
get ':id/managed', action: :managed_users
get 'role/:name', action: :role_users
end

member do
get :get_profile # GET /api/v1/users/:id/get_profile
patch :update_profile # PATCH /api/v1/users/:id/update_profile
post :update_password # POST /api/v1/users/:id/update_password
end
end
resources :assignments do
collection do
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20250422214327_add_jwt_version_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddJwtVersionToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :jwt_version, :string
end
end
Loading
Loading