Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
44 changes: 44 additions & 0 deletions app/mailers/password_reset_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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