diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..d56abbf304 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 481e82b6c2..3ef1f7a03a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,4 @@ student-work/ .idea/ .byebug_history coverage/ -.vscode _history diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..7e6882bfa3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} diff --git a/Gemfile.lock b/Gemfile.lock index 58ca5d5924..000d70b11a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -490,6 +493,7 @@ GEM PLATFORMS aarch64-linux + x86_64-linux DEPENDENCIES better_errors diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index 3b43b10cf6..693f2b9f00 100644 --- a/app/api/authentication_api.rb +++ b/app/api/authentication_api.rb @@ -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 # diff --git a/app/mailers/password_reset_mailer.rb b/app/mailers/password_reset_mailer.rb new file mode 100644 index 0000000000..9065aa45f1 --- /dev/null +++ b/app/mailers/password_reset_mailer.rb @@ -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 + + diff --git a/app/models/user.rb b/app/models/user.rb index 6e016badfd..1afdc9c345 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 # diff --git a/app/views/password_reset_mailer/password_changed.html.erb b/app/views/password_reset_mailer/password_changed.html.erb new file mode 100644 index 0000000000..051f30f5cc --- /dev/null +++ b/app/views/password_reset_mailer/password_changed.html.erb @@ -0,0 +1,97 @@ + + + + + + Password Changed - <%= @doubtfire_product_name %> + + + +
+ +
+ +
+

Password Successfully Changed

+ +

Hello <%= @user.name %>,

+ +
+ ✓ Password Changed Successfully
+ Your password for <%= @doubtfire_product_name %> has been successfully updated. +
+ +

This is a confirmation that your password was changed at <%= Time.current.strftime("%B %d, %Y at %I:%M %p") %>.

+ +
+ Security Notice: If you did not make this change, please contact your system administrator immediately as your account may have been compromised. +
+ +

You can now log in to <%= @doubtfire_product_name %> using your new password.

+ +

For security reasons, we recommend:

+ +
+ + + + + + diff --git a/app/views/password_reset_mailer/password_changed.text.erb b/app/views/password_reset_mailer/password_changed.text.erb new file mode 100644 index 0000000000..9cf892d6e4 --- /dev/null +++ b/app/views/password_reset_mailer/password_changed.text.erb @@ -0,0 +1,24 @@ +Password Changed Successfully - <%= @doubtfire_product_name %> + +Hello <%= @user.name %>, + +✓ PASSWORD CHANGED SUCCESSFULLY + +Your password for <%= @doubtfire_product_name %> has been successfully updated. + +This is a confirmation that your password was changed at <%= Time.current.strftime("%B %d, %Y at %I:%M %p") %>. + +SECURITY NOTICE: If you did not make this change, please contact your system administrator immediately as your account may have been compromised. + +You can now log in to <%= @doubtfire_product_name %> using your new password. + +For security reasons, we recommend: +- Using a strong, unique password +- Not sharing your password with anyone +- Changing your password regularly + +--- +This email was sent from <%= @doubtfire_product_name %> +If you have any questions or concerns, please contact your system administrator. + + diff --git a/app/views/password_reset_mailer/reset_password.html.erb b/app/views/password_reset_mailer/reset_password.html.erb new file mode 100644 index 0000000000..24bb1e5330 --- /dev/null +++ b/app/views/password_reset_mailer/reset_password.html.erb @@ -0,0 +1,101 @@ + + + + + + Password Reset - <%= @doubtfire_product_name %> + + + +
+ +
+ +
+

Password Reset Request

+ +

Hello <%= @user.name %>,

+ +

We received a request to reset your password for your <%= @doubtfire_product_name %> account. If you made this request, click the button below to reset your password:

+ +

+ Reset My Password +

+ +
+ Important: This link will expire in <%= @expiry_hours %> hours for security reasons. +
+ +

If the button doesn't work, you can copy and paste this link into your browser:

+

+ <%= @reset_url %> +

+ +

If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.

+ +

If you continue to have problems, please contact your system administrator.

+
+ + + + + + diff --git a/app/views/password_reset_mailer/reset_password.text.erb b/app/views/password_reset_mailer/reset_password.text.erb new file mode 100644 index 0000000000..c044d203b6 --- /dev/null +++ b/app/views/password_reset_mailer/reset_password.text.erb @@ -0,0 +1,19 @@ +Password Reset Request - <%= @doubtfire_product_name %> + +Hello <%= @user.name %>, + +We received a request to reset your password for your <%= @doubtfire_product_name %> account. If you made this request, please click the link below to reset your password: + +<%= @reset_url %> + +IMPORTANT: This link will expire in <%= @expiry_hours %> hours for security reasons. + +If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. + +If you continue to have problems, please contact your system administrator. + +--- +This email was sent from <%= @doubtfire_product_name %> +If you have any questions, please contact your system administrator. + + diff --git a/test/api/password_management_test.rb b/test/api/password_management_test.rb new file mode 100644 index 0000000000..384529bc03 --- /dev/null +++ b/test/api/password_management_test.rb @@ -0,0 +1,202 @@ +require 'test_helper' + +class PasswordManagementTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + setup do + @user = User.create!( + username: 'testuser', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + password: 'password123', + password_confirmation: 'password123', + role_id: Role.student.id, + login_id: 'testuser' + ) + end + + test "should register new user with valid data" do + post '/api/register', params: { + username: 'newuser', + email: 'newuser@example.com', + password: 'password123', + password_confirmation: 'password123', + first_name: 'New', + last_name: 'User' + } + + assert_response :success + assert_equal 'newuser', JSON.parse(response.body)['user']['username'] + assert_not_nil JSON.parse(response.body)['auth_token'] + end + + test "should not register user with existing username" do + post '/api/register', params: { + username: 'testuser', + email: 'different@example.com', + password: 'password123', + password_confirmation: 'password123', + first_name: 'Different', + last_name: 'User' + } + + assert_response :conflict + assert_includes JSON.parse(response.body)['error'], 'Username already exists' + end + + test "should not register user with existing email" do + post '/api/register', params: { + username: 'differentuser', + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123', + first_name: 'Different', + last_name: 'User' + } + + assert_response :conflict + assert_includes JSON.parse(response.body)['error'], 'Email already exists' + end + + test "should not register user with short password" do + post '/api/register', params: { + username: 'newuser', + email: 'newuser@example.com', + password: 'short', + password_confirmation: 'short', + first_name: 'New', + last_name: 'User' + } + + assert_response :unprocessable_entity + assert_includes JSON.parse(response.body)['details'], 'Password is too short' + end + + test "should not register user with mismatched passwords" do + post '/api/register', params: { + username: 'newuser', + email: 'newuser@example.com', + password: 'password123', + password_confirmation: 'different123', + first_name: 'New', + last_name: 'User' + } + + assert_response :unprocessable_entity + assert_includes JSON.parse(response.body)['details'], "doesn't match password" + end + + test "should request password reset for existing user" do + assert_emails 1 do + post '/api/password/reset', params: { email: 'test@example.com' } + end + + assert_response :success + assert_includes JSON.parse(response.body)['message'], 'password reset link has been sent' + + @user.reload + assert_not_nil @user.reset_password_token + assert_not_nil @user.reset_password_sent_at + + # Check that the email was sent + email = ActionMailer::Base.deliveries.last + assert_equal 'test@example.com', email.to[0] + assert_includes email.subject, 'Password Reset Request' + end + + test "should not reveal if email exists for password reset" do + post '/api/password/reset', params: { email: 'nonexistent@example.com' } + + assert_response :success + assert_includes JSON.parse(response.body)['message'], 'password reset link has been sent' + end + + test "should reset password with valid token" do + @user.generate_password_reset_token! + token = @user.reset_password_token + + assert_emails 1 do + post '/api/password/reset/confirm', params: { + token: token, + password: 'newpassword123', + password_confirmation: 'newpassword123' + } + end + + assert_response :success + assert_includes JSON.parse(response.body)['message'], 'Password has been reset' + + @user.reload + assert_nil @user.reset_password_token + assert_nil @user.reset_password_sent_at + assert @user.valid_password?('newpassword123') + + # Check that the password changed notification email was sent + email = ActionMailer::Base.deliveries.last + assert_equal 'test@example.com', email.to[0] + assert_includes email.subject, 'Password Changed Successfully' + end + + test "should not reset password with invalid token" do + post '/api/password/reset/confirm', params: { + token: 'invalid_token', + password: 'newpassword123', + password_confirmation: 'newpassword123' + } + + assert_response :bad_request + assert_includes JSON.parse(response.body)['error'], 'Invalid or expired reset token' + end + + test "should change password with correct current password" do + auth_token = @user.generate_authentication_token!(false).authentication_token + + assert_emails 1 do + post '/api/password/change', params: { + current_password: 'password123', + password: 'newpassword123', + password_confirmation: 'newpassword123' + }, headers: { + 'Auth-Token' => auth_token, + 'Username' => @user.username + } + end + + assert_response :success + assert_includes JSON.parse(response.body)['message'], 'Password has been changed' + + @user.reload + assert @user.valid_password?('newpassword123') + + # Check that the password changed notification email was sent + email = ActionMailer::Base.deliveries.last + assert_equal 'test@example.com', email.to[0] + assert_includes email.subject, 'Password Changed Successfully' + end + + test "should not change password with incorrect current password" do + auth_token = @user.generate_authentication_token!(false).authentication_token + + post '/api/password/change', params: { + current_password: 'wrongpassword', + password: 'newpassword123', + password_confirmation: 'newpassword123' + }, headers: { + 'Auth-Token' => auth_token, + 'Username' => @user.username + } + + assert_response :bad_request + assert_includes JSON.parse(response.body)['error'], 'Current password is incorrect' + end + + test "should require authentication for password change" do + post '/api/password/change', params: { + current_password: 'password123', + password: 'newpassword123', + password_confirmation: 'newpassword123' + } + + assert_response :unauthorized + end +end