diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..15812eb44 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,43 @@ +class 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] + + # POST /password_resets + def create + if @user + token = @user.generate_token_for(:password_reset) + UserMailer.send_password_reset_email(token).deliver_later + end + + # Always return a 200 OK to prevent email enumeration attacks + render json: { message: "If the email exists, a reset link has been sent." }, status: :ok + end + + # PATCH/PUT /password_resets/:token + def update + if @user.update(password_params) + 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_token_for(:password_reset, params[:token]) + + unless @user + render json: { error: "The token has expired or is invalid." }, status: :unprocessable_entity + end + 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..6b8b28d21 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,8 @@ +class UserMailer < ApplicationMailer + default from: "expertizamailer@gmail.com" + + def send_password_reset_email(token) + @reset_url = "http://localhost:3000/password_edit/check_reset_url?token=#{token}" + mail(to: @user.email, subject: 'Expertiza password reset') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 0e77e25dc..0630e2259 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -48,6 +48,10 @@ def self.instantiate(record) end end + generates_token_for :password_reset, expires_in: 15.minutes do + password_salt&.last(10) || updated_at.to_s + end + # Welcome email to be sent to the user after they sign up def welcome_email; 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 @@ + + + Expertiza password reset + + + +

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:

+

<%= @reset_url %>

+

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 996589b2b..3777aa680 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -55,6 +55,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 b66a6ff50..790fa4c89 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -172,4 +172,6 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] end -end \ No newline at end of file + + resources :password_resets, only: [:create, :update], controller: "passwords", param: :token +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