diff --git a/README.md b/README.md index 6c91b930b..7e6d5bb86 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,24 @@ alt="IMAGE ALT TEXT HERE" width="560" height="315" border="10" /> ### Database Credentials - username: root - password: expertiza + + +# Password Reset Testing Guide + +## Overview +The deployment does not allow creating new users. To facilitate testing, we have manually added a test email into the database. Follow the steps below to test the password reset functionality. + +## Steps for Testing + +1) Go to [http://152.7.177.227:3000/login](http://152.7.177.227:3000/login) and click on 'forget password' button. + +2) Input the email address: **testoodd1234@gmail.com** and click on request password. + +3) Open another tab and log into Gmail using the following credentials: + + - **Email:** `testoodd1234@gmail.com` + - **Pass:** `Test@1234` + +4) After logging in, you should be able to see the inbox and there should be an email from Expertiza Mailer(check spam folder if you don't see the email). Open the email and click on the link to reset the password. + +5) Type in the new password and reset it. Then head back to [http://152.7.177.227:3000/login](http://152.7.177.227:3000/login) and try logging in with the email and the new password that you set up. diff --git a/app/controllers/api/v1/passwords_controller.rb b/app/controllers/api/v1/passwords_controller.rb new file mode 100644 index 000000000..83f8ca5b6 --- /dev/null +++ b/app/controllers/api/v1/passwords_controller.rb @@ -0,0 +1,44 @@ +class Api::V1::PasswordsController < ApplicationController + before_action :find_user_by_email, only: [:create] + before_action :find_user_by_token, only: [:update] + skip_before_action :authenticate_request!, only: [:create, :update] + + # User requests a password reset + def create + if @user + @user.generate_password_reset_token! + UserMailer.send_password_reset_email(@user).deliver_later + render json: { message: "If the email exists, a reset link has been sent." }, status: :ok + else + render json: { error: "No account is associated with the e-mail address: #{params[:email]}. Please try again." }, status: :not_found + end + end + + # Update password + def update + if !@user.password_reset_valid? + render json: { error: "The token has expired or is invalid." }, status: :unprocessable_entity + elsif @user.update(password_params) + @user.clear_password_reset_token! + render json: { message: "Password successfully updated." }, status: :ok + else + render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def find_user_by_email + @user = User.find_by(email: params[:email]) + end + + def find_user_by_token + @user = User.find_by(reset_password_token: params[:token]) + return render json: { error: "Invalid or expired token." }, status: :unprocessable_entity unless @user&.password_reset_valid? + end + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end + end + \ No newline at end of file diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..6394b161c --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,9 @@ +class UserMailer < ApplicationMailer + default from: "expertizamailer@gmail.com" + + def send_password_reset_email(user) + @user = user + @reset_url = "http://localhost:3000/password_edit/check_reset_url?token=#{@user.reset_password_token}" + mail(to: @user.email, subject: 'Expertiza password reset') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index ebcdf9ed0..ec46faee8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -114,4 +114,24 @@ def set_defaults self.etc_icons_on_homepage ||= true end + + validates :reset_password_token, uniqueness: true, allow_nil: true + + # Method to generate reset password token + def generate_password_reset_token! + self.reset_password_token = SecureRandom.urlsafe_base64 + self.reset_password_sent_at = Time.zone.now + save! + end + + # Method to clear the reset token after a successful password reset + def clear_password_reset_token! + update(reset_password_token: nil, reset_password_sent_at: nil) + end + + # Method to check if the password reset token is valid (within 24 hours) + def password_reset_valid? + (reset_password_sent_at + 24.hours) > Time.zone.now + end + end diff --git a/app/views/user_mailer/send_password_reset_email.html.erb b/app/views/user_mailer/send_password_reset_email.html.erb new file mode 100644 index 000000000..fe5c4742f --- /dev/null +++ b/app/views/user_mailer/send_password_reset_email.html.erb @@ -0,0 +1,14 @@ + +
+Hi ,
+Reset your password, and we'll get you on your way.
+To change your password, click or paste the following link into your browser:
+ +The link will expire in 24 hours, so be sure to use it right away.
+ + + diff --git a/config/environments/development.rb b/config/environments/development.rb index 37737bd0a..13753076e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -53,6 +53,20 @@ # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true + config.action_mailer.smtp_settings = { + address: 'smtp.gmail.com', + port: 587, + domain: 'localhost', + user_name: 'expertiza.mailer@gmail.com', + password: 'xdgmnehqevkevkqy', # This password should come from a .env file + authentication: 'plain', + enable_starttls_auto: true + } + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/routes.rb b/config/routes.rb index e5d805c4f..ad8771a79 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ post '/login', to: 'authentication#login' namespace :api do namespace :v1 do + resources :password_resets, only: [:create, :update], controller: "passwords", param: :token resources :institutions resources :roles do collection do @@ -120,6 +121,8 @@ delete '/:id', to: 'participants#destroy' end end + + resources :password_resets, only: [:create, :update] end end end \ No newline at end of file diff --git a/db/migrate/20250324033626_add_password_reset_fields_to_users.rb b/db/migrate/20250324033626_add_password_reset_fields_to_users.rb new file mode 100644 index 000000000..0416a0a78 --- /dev/null +++ b/db/migrate/20250324033626_add_password_reset_fields_to_users.rb @@ -0,0 +1,6 @@ +class AddPasswordResetFieldsToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :reset_password_token, :string + add_column :users, :reset_password_sent_at, :datetime + end +end diff --git a/spec/controllers/api/v1/passwords_controller_spec.rb b/spec/controllers/api/v1/passwords_controller_spec.rb new file mode 100644 index 000000000..46f41cd8b --- /dev/null +++ b/spec/controllers/api/v1/passwords_controller_spec.rb @@ -0,0 +1,90 @@ +require 'rails_helper' + +RSpec.describe Api::V1::PasswordsController, type: :controller do + let(:user) { create(:user) } + let(:valid_password_params) { { user: { password: 'newpassword123', password_confirmation: 'newpassword123' } } } + let(:invalid_password_params) { { user: { password: 'short', password_confirmation: 'short' } } } + + describe 'PasswordsController' do + describe '#create' do + context 'when the email exists' do + before do + allow(UserMailer).to receive_message_chain(:send_password_reset_email, :deliver_later) + post :create, params: { email: user.email } + end + + it 'generates a password reset token' do + user.reload + expect(user.reset_password_token).to be_present + end + + it 'sends a password reset email' do + expect(UserMailer).to have_received(:send_password_reset_email).with(user) + end + + it 'returns a success message' do + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['message']).to eq("If the email exists, a reset link has been sent.") + end + end + + context 'when the email does not exist' do + before do + post :create, params: { email: 'nonexistent@example.com' } + end + + it 'returns an error message' do + expect(response).to have_http_status(:not_found) + expect(JSON.parse(response.body)['error']).to eq("No account is associated with the e-mail address: nonexistent@example.com. Please try again.") + end + end + end + + describe '#update' do + context 'when the token is valid' do + before do + user.generate_password_reset_token! + put :update, params: { token: user.reset_password_token }.merge(valid_password_params) + end + + it 'updates the password' do + user.reload + expect(user.authenticate('newpassword123')).to be_truthy + end + + it 'clears the password reset token' do + user.reload + expect(user.reset_password_token).to be_nil + end + + it 'returns a success message' do + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['message']).to eq("Password successfully updated.") + end + end + + context 'when the token is invalid or expired' do + before do + put :update, params: { token: 'invalidtoken' }.merge(valid_password_params) + end + + it 'returns an error message' do + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['error']).to eq("Invalid or expired token.") + end + end + + context 'when the password is invalid' do + before do + user.generate_password_reset_token! + put :update, params: { token: user.reset_password_token }.merge(invalid_password_params) + end + + it 'returns validation errors' do + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['errors']).to include("Password is too short (minimum is 8 characters)") + end + end + end + end +end \ No newline at end of file diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 000000000..118cf9de6 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,14 @@ +# spec/factories/users.rb +FactoryBot.factories.clear +FactoryBot.define do + factory :user do + email { Faker::Internet.email } + password { 'password123' } + password_confirmation { 'password123' } + name { Faker::Name.first_name } + full_name { Faker::Name.name } + association :role + reset_password_sent_at { nil } + end + end + \ No newline at end of file diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..8e199b13b --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,146 @@ +# spec/models/user_spec.rb +require 'rails_helper' + +RSpec.describe User, type: :model do + + let(:role) { create(:role, :student) } + let(:institution) { create(:institution) } + + let(:user) { create(:user, role: role, institution: institution) } + + describe 'validations' do + it 'validates presence of name' do + user.name = nil + expect(user).not_to be_valid + expect(user.errors[:name]).to include("can't be blank") + end + + it 'validates uniqueness of name' do + duplicate_user = user.dup + duplicate_user.name = user.name + duplicate_user.save + expect(duplicate_user.errors[:name]).to include('has already been taken') + end + + it 'validates presence of email' do + user.email = nil + expect(user).not_to be_valid + expect(user.errors[:email]).to include("can't be blank") + end + + it 'validates email format' do + user.email = 'invalid_email' + expect(user).not_to be_valid + expect(user.errors[:email]).to include('is invalid') + end + + it 'validates password length' do + user.password = 'short' + expect(user).not_to be_valid + expect(user.errors[:password]).to include('is too short (minimum is 6 characters)') + end + + it 'validates presence of full_name' do + user.full_name = nil + expect(user).not_to be_valid + expect(user.errors[:full_name]).to include("can't be blank") + end + end + + describe 'associations' do + it { should belong_to(:role) } + it { should belong_to(:institution).optional } + it { should belong_to(:parent).class_name('User').optional } + it { should have_many(:users).dependent(:nullify) } + it { should have_many(:invitations) } + it { should have_many(:assignments) } + it { should have_many(:teams_users).dependent(:destroy) } + it { should have_many(:teams).through(:teams_users) } + it { should have_many(:participants) } + end + + describe 'callbacks' do + it 'sets default values on initialization' do + new_user = User.new + expect(new_user.is_new_user).to be true + expect(new_user.copy_of_emails).to be false + expect(new_user.email_on_review).to be false + expect(new_user.email_on_submission).to be false + expect(new_user.email_on_review_of_review).to be false + expect(new_user.etc_icons_on_homepage).to be true + end + end + + describe '#login_user' do + it 'returns a user when login is email' do + result = User.login_user(user.email) + expect(result).to eq(user) + end + + it 'returns a user when login is name' do + result = User.login_user(user.name) + expect(result).to eq(user) + end + + it 'returns nil if no user is found' do + result = User.login_user('nonexistent_user') + expect(result).to be_nil + end + end + + describe '#reset_password' do + it 'resets the password and saves the user' do + old_password_digest = user.password_digest + user.reset_password + expect(user.password_digest).not_to eq(old_password_digest) + expect(user.save).to be_truthy + end + end + + describe '#instructor_id' do + it 'returns the user id if the user is an instructor' do + user.update(role: create(:role, :instructor)) + expect(user.instructor_id).to eq(user.id) + end + + it 'returns the instructor id if the user is a teaching assistant' do + ta_user = create(:user, role: create(:role, :ta)) + instructor = create(:user, role: create(:role, :instructor)) + ta_user.update(parent: instructor) + expect(ta_user.instructor_id).to eq(instructor.id) + end + end + + describe '#generate_password_reset_token!' do + it 'generates and saves a reset password token' do + expect(user.reset_password_token).to be_nil + user.generate_password_reset_token! + expect(user.reset_password_token).not_to be_nil + expect(user.reset_password_sent_at).not_to be_nil + end + end + + describe '#clear_password_reset_token!' do + it 'clears the reset password token' do + user.generate_password_reset_token! + token = user.reset_password_token + user.clear_password_reset_token! + expect(user.reset_password_token).to be_nil + expect(user.reset_password_sent_at).to be_nil + end + end + + describe '#password_reset_valid?' do + it 'returns true if the reset password token is valid (within 24 hours)' do + user.generate_password_reset_token! + user.reset_password_sent_at = Time.zone.now - 1.hour + expect(user.password_reset_valid?).to be true + end + + it 'returns false if the reset password token is expired (older than 24 hours)' do + user.generate_password_reset_token! + user.reset_password_sent_at = Time.zone.now - 25.hours + expect(user.password_reset_valid?).to be false + end + end +end