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 .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto eol=lf
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,4 @@ student-work/
.idea/
.byebug_history
coverage/
.vscode
_history
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"files.eol": "\n"
}
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ GEM
faraday-net_http (3.1.0)
net-http
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-x86_64-linux-gnu)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
Expand Down Expand Up @@ -265,6 +266,8 @@ GEM
nio4r (2.7.3)
nokogiri (1.16.5-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
observer (0.1.2)
orm_adapter (0.5.0)
parallel (1.24.0)
Expand Down Expand Up @@ -490,6 +493,7 @@ GEM

PLATFORMS
aarch64-linux
x86_64-linux

DEPENDENCIES
better_errors
Expand Down
170 changes: 170 additions & 0 deletions app/api/authentication_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,176 @@ class AuthenticationApi < Grape::API
present response, with: Grape::Presenters::Presenter
end

#
# Password management endpoints - only available for database auth
#
if !AuthenticationHelpers.aaf_auth? && !AuthenticationHelpers.saml_auth? && !AuthenticationHelpers.ldap_auth?

#
# User registration endpoint
#
desc 'Register a new user'
params do
requires :username, type: String, desc: 'User username'
requires :email, type: String, desc: 'User email'
requires :password, type: String, desc: 'User password'
requires :password_confirmation, type: String, desc: 'Password confirmation'
requires :first_name, type: String, desc: 'User first name'
requires :last_name, type: String, desc: 'User last name'
optional :nickname, type: String, desc: 'User nickname'
end
post '/register' do
username = params[:username].downcase
email = params[:email]
password = params[:password]
password_confirmation = params[:password_confirmation]

# Check if user already exists
if User.exists?(username: username)
error!({ error: 'Username already exists.' }, 409)
end

if User.exists?(email: email)
error!({ error: 'Email already exists.' }, 409)
end

# Create new user
user = User.new(
username: username,
email: email,
password: password,
password_confirmation: password_confirmation,
first_name: params[:first_name],
last_name: params[:last_name],
nickname: params[:nickname] || params[:first_name],
role_id: Role.student.id,
login_id: username
)

if user.save
logger.info "User registered: #{username} from #{request.ip}"
present :user, user, with: Entities::UserEntity
present :auth_token, user.generate_authentication_token!(false).authentication_token
present :message, 'User registered successfully.'
else
error!({ error: 'Registration failed.', details: user.errors.full_messages }, 422)
end
end

#
# Password reset request endpoint
#
desc 'Request password reset'
params do
requires :email, type: String, desc: 'User email'
end
post '/password/reset' do
email = params[:email]
user = User.find_by(email: email)

if user
user.generate_password_reset_token!

# Send password reset email
begin
PasswordResetMailer.reset_password(user).deliver_now
logger.info "Password reset email sent to #{email}"
rescue => e
logger.error "Failed to send password reset email to #{email}: #{e.message}"
# Don't fail the request if email sending fails
end

present :message, 'If an account with that email exists, a password reset link has been sent.'
else
# Don't reveal whether email exists for security
present :message, 'If an account with that email exists, a password reset link has been sent.'
end
end

#
# Password reset confirmation endpoint
#
desc 'Reset password with token'
params do
requires :token, type: String, desc: 'Password reset token'
requires :password, type: String, desc: 'New password'
requires :password_confirmation, type: String, desc: 'Password confirmation'
end
post '/password/reset/confirm' do
token = params[:token]
password = params[:password]
password_confirmation = params[:password_confirmation]

user = User.find_by(reset_password_token: token)

unless user && user.password_reset_token_valid?
error!({ error: 'Invalid or expired reset token.' }, 400)
end

user.password = password
user.password_confirmation = password_confirmation

if user.save
user.clear_password_reset_token!
logger.info "Password reset completed for user: #{user.username} from #{request.ip}"

# Send password changed notification email
begin
PasswordResetMailer.password_changed(user).deliver_now
logger.info "Password changed notification email sent to #{user.email}"
rescue => e
logger.error "Failed to send password changed notification email to #{user.email}: #{e.message}"
# Don't fail the request if email sending fails
end

present :message, 'Password has been reset successfully.'
else
error!({ error: 'Password reset failed.', details: user.errors.full_messages }, 422)
end
end

#
# Change password endpoint (requires authentication)
#
desc 'Change password'
params do
requires :current_password, type: String, desc: 'Current password'
requires :password, type: String, desc: 'New password'
requires :password_confirmation, type: String, desc: 'Password confirmation'
end
post '/password/change' do
authenticate!

current_password = params[:current_password]
password = params[:password]
password_confirmation = params[:password_confirmation]

unless current_user.valid_password?(current_password)
error!({ error: 'Current password is incorrect.' }, 400)
end

current_user.password = password
current_user.password_confirmation = password_confirmation

if current_user.save
logger.info "Password changed for user: #{current_user.username} from #{request.ip}"

# Send password changed notification email
begin
PasswordResetMailer.password_changed(current_user).deliver_now
logger.info "Password changed notification email sent to #{current_user.email}"
rescue => e
logger.error "Failed to send password changed notification email to #{current_user.email}: #{e.message}"
# Don't fail the request if email sending fails
end

present :message, 'Password has been changed successfully.'
else
error!({ error: 'Password change failed.', details: current_user.errors.full_messages }, 422)
end
end
end

#
# Returns the current auth signout URL
#
Expand Down
45 changes: 45 additions & 0 deletions app/mailers/password_reset_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
class PasswordResetMailer < ActionMailer::Base
def reset_password(user)
@doubtfire_host = Doubtfire::Application.config.institution[:host]
@doubtfire_product_name = Doubtfire::Application.config.institution[:product_name]

@user = user
@reset_url = "#{@doubtfire_host}/#/reset-password?token=#{user.reset_password_token}"
@expiry_hours = 24

# Set the default from address
institution_email_domain = Doubtfire::Application.config.institution[:email_domain]
default_from = "noreply@#{institution_email_domain}"

# Create email with user's name
email_with_name = %("#{@user.name}" <#{@user.email}>)

mail(
to: email_with_name,
from: default_from,
subject: "[#{@doubtfire_product_name}] Password Reset Request"
)
end

def password_changed(user)
@doubtfire_host = Doubtfire::Application.config.institution[:host]
@doubtfire_product_name = Doubtfire::Application.config.institution[:product_name]

@user = user

# Set the default from address
institution_email_domain = Doubtfire::Application.config.institution[:email_domain]
default_from = "noreply@#{institution_email_domain}"

# Create email with user's name
email_with_name = %("#{@user.name}" <#{@user.email}>)

mail(
to: email_with_name,
from: default_from,
subject: "[#{@doubtfire_product_name}] Password Changed Successfully"
)
end
end


52 changes: 46 additions & 6 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,58 @@ def valid_jwt?(jws)
end

#
# We incorporate password details for local dev server - needed to keep devise happy
# Password management methods
#
def password
'password'
attr_accessor :password, :password_confirmation

# Password validation
validates :password, presence: true, length: { minimum: 8 }, on: :create
validates :password, presence: true, length: { minimum: 8 }, on: :update, if: :password_required?
validates :password_confirmation, presence: true, if: :password_required?
validate :password_confirmation_match, if: :password_required?

def password_required?
password.present? || password_confirmation.present?
end

def password_confirmation
'password'
def password_confirmation_match
if password != password_confirmation
errors.add(:password_confirmation, "doesn't match password")
end
end

def password=(value)
self.encrypted_password = BCrypt::Password.create(value)
@password = value
if value.present?
self.encrypted_password = BCrypt::Password.create(value)
end
end

# Check if provided password matches stored password
def valid_password?(password)
return false if encrypted_password.blank?
BCrypt::Password.new(encrypted_password) == password
end

# Generate password reset token
def generate_password_reset_token!
self.reset_password_token = SecureRandom.urlsafe_base64
self.reset_password_sent_at = Time.current
save!(validate: false)
end

# Clear password reset token
def clear_password_reset_token!
self.reset_password_token = nil
self.reset_password_sent_at = nil
save!(validate: false)
end

# Check if password reset token is valid and not expired (24 hours)
def password_reset_token_valid?
reset_password_token.present? &&
reset_password_sent_at.present? &&
reset_password_sent_at > 24.hours.ago
end

#
Expand Down
Loading