diff --git a/.template-infra/app-app-rails.yml b/.template-infra/app-app-rails.yml index 660a47d1a..2f929638e 100644 --- a/.template-infra/app-app-rails.yml +++ b/.template-infra/app-app-rails.yml @@ -4,4 +4,4 @@ _src_path: https://github.com/navapbc/template-infra app_has_dev_env_setup: true app_local_port: 3100 app_name: app-rails -template: app +template: app \ No newline at end of file diff --git a/app-rails/Gemfile b/app-rails/Gemfile index 4935f1baf..d70e40133 100644 --- a/app-rails/Gemfile +++ b/app-rails/Gemfile @@ -1,6 +1,7 @@ source "https://rubygems.org" ruby "3.4.2" +gem "cgi", ">= 0.4.2" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 7.2.0", ">= 7.2.2.1" diff --git a/app-rails/Gemfile.lock b/app-rails/Gemfile.lock index e5823166b..cb64468b0 100644 --- a/app-rails/Gemfile.lock +++ b/app-rails/Gemfile.lock @@ -124,6 +124,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.4.2) childprocess (5.1.0) logger (~> 1.5) choice (0.2.0) @@ -458,6 +459,7 @@ DEPENDENCIES aws-sdk-s3 bootsnap capybara + cgi (>= 0.4.2) cssbundling-rails (~> 1.4) debug devise diff --git a/app-rails/README.md b/app-rails/README.md index 70391fdcf..ed00e9d8b 100644 --- a/app-rails/README.md +++ b/app-rails/README.md @@ -96,6 +96,29 @@ To run natively: 1. `make start-native` 1. Then visit http://localhost:3100 +### Enabling Local Login for Development + +For local development (without AWS Cognito credentials), you can use local login via Devise. To enable local login: + +1. **Set Rails environment to development**: + + ```bash + RAILS_ENV=development + +2. **Enable Devise in development by setting the environment variable:**: + ```bash + USE_DEVISE=true + +3. **Set up the database (run migrations):**: + ```bash + rails db:migrate + +4. **Follow the instructions for starting a container then start it:**: + ```bash + make start-container + +5. **Visit the sign-up page: Go to http://localhost:3100/users/sign_up and register with any email and password.:**: + #### IDE tips
diff --git a/app-rails/app/controllers/users/accounts_controller.rb b/app-rails/app/controllers/users/accounts_controller.rb index fcc8345b9..212155e28 100644 --- a/app-rails/app/controllers/users/accounts_controller.rb +++ b/app-rails/app/controllers/users/accounts_controller.rb @@ -27,7 +27,7 @@ def update_email private def auth_service - AuthService.new + AuthServiceFactory.instance.auth_service end def user_email_params diff --git a/app-rails/app/controllers/users/mfa_controller.rb b/app-rails/app/controllers/users/mfa_controller.rb index 615db4580..aadc4236b 100644 --- a/app-rails/app/controllers/users/mfa_controller.rb +++ b/app-rails/app/controllers/users/mfa_controller.rb @@ -67,6 +67,6 @@ def destroy private def auth_service - AuthService.new + AuthServiceFactory.instance.auth_service end end diff --git a/app-rails/app/controllers/users/passwords_controller.rb b/app-rails/app/controllers/users/passwords_controller.rb index 706848696..9fe30f056 100644 --- a/app-rails/app/controllers/users/passwords_controller.rb +++ b/app-rails/app/controllers/users/passwords_controller.rb @@ -56,7 +56,7 @@ def confirm_reset private def auth_service - AuthService.new + AuthServiceFactory.instance.auth_service end def reset_password_params diff --git a/app-rails/app/controllers/users/sessions_controller.rb b/app-rails/app/controllers/users/sessions_controller.rb index 8f4a51c80..2a796ee17 100644 --- a/app-rails/app/controllers/users/sessions_controller.rb +++ b/app-rails/app/controllers/users/sessions_controller.rb @@ -4,38 +4,68 @@ class Users::SessionsController < Devise::SessionsController layout "users" skip_after_action :verify_authorized + # Disable CSRF for API requests + protect_from_forgery with: :null_session, if: -> { request.format.json? } + respond_to :html, :json + def new @form = Users::NewSessionForm.new end def create - @form = Users::NewSessionForm.new(new_session_params) - - if @form.invalid? - flash.now[:errors] = @form.errors.full_messages - return render :new, status: :unprocessable_entity + if Rails.application.config.auth_provider == :devise + permitted_params = params.require(:users_new_session_form).permit(:email, :password) + @form = Users::NewSessionForm.new(permitted_params) + + if @form.invalid? + flash[:alert] = "Invalid request. Please try again." + return render :new, status: :unprocessable_entity + end + + user_params = { "email" => @form.email, "password" => @form.password } + user = User.find_by(email: user_params["email"]) + + if user.nil? || !user.valid_password?(user_params["password"]) + Rails.logger.debug "Login failed: invalid credentials for #{user_params['email']}" + flash[:alert] = "Invalid email or password" + return render :new, status: :unauthorized + end + + # Authenticate user via Warden + warden = request.env["warden"] + warden.set_user(user, scope: :user) + + redirect_to root_path, notice: "Signed in successfully!" + else + + @form = Users::NewSessionForm.new(new_session_params) + + if @form.invalid? + flash.now[:errors] = @form.errors.full_messages + return render :new, status: :unprocessable_entity + end + + begin + response = auth_service.initiate_auth( + @form.email, + @form.password + ) + rescue Auth::Errors::UserNotConfirmed => e + return redirect_to users_verify_account_path + rescue Auth::Errors::BaseAuthError => e + flash.now[:errors] = [ e.message ] + return render :new, status: :unprocessable_entity + end + + unless response[:user].present? + puts response.inspect + session[:challenge_session] = response[:session] + session[:challenge_email] = @form.email + return redirect_to session_challenge_path + end + + auth_user(response[:user], response[:access_token]) end - - begin - response = auth_service.initiate_auth( - @form.email, - @form.password - ) - rescue Auth::Errors::UserNotConfirmed => e - return redirect_to users_verify_account_path - rescue Auth::Errors::BaseAuthError => e - flash.now[:errors] = [ e.message ] - return render :new, status: :unprocessable_entity - end - - unless response[:user].present? - puts response.inspect - session[:challenge_session] = response[:session] - session[:challenge_email] = @form.email - return redirect_to session_challenge_path - end - - auth_user(response[:user], response[:access_token]) end # Show MFA diff --git a/app-rails/app/models/user.rb b/app-rails/app/models/user.rb index dd4f9e743..ce87023ad 100644 --- a/app-rails/app/models/user.rb +++ b/app-rails/app/models/user.rb @@ -1,5 +1,9 @@ class User < ApplicationRecord - devise :cognito_authenticatable, :timeoutable + if Rails.env.development? && !ENV["USE_DEVISE"].blank? + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable + else + devise :cognito_authenticatable, :timeoutable + end attr_accessor :access_token # == Enums ======================================================== diff --git a/app-rails/app/services/auth_service.rb b/app-rails/app/services/auth_service.rb index 3c98e680a..25da6e602 100644 --- a/app-rails/app/services/auth_service.rb +++ b/app-rails/app/services/auth_service.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class AuthService - def initialize(auth_adapter = Auth::CognitoAdapter.new) + def initialize(auth_adapter = AuthServiceFactory.instance.auth_service) @auth_adapter = auth_adapter end + def create_account(email, password) + @auth_adapter.create_account(email, password) + end + # Send a confirmation code that's required to change the user's password def forgot_password(email) @auth_adapter.forgot_password(email) @@ -34,12 +38,12 @@ def respond_to_auth_challenge(code, challenge = {}) handle_auth_result(response, challenge[:email]) end - def register(email, password) + def register(email, password, role = "applicant") # @TODO: Handle errors from the auth service, like when the email is already taken # See https://github.com/navapbc/template-application-rails/issues/15 account = @auth_adapter.create_account(email, password) - create_db_user(account[:uid], email, account[:provider]) + create_db_user(account[:uid], email, account[:provider], password, role) end # Verify the code sent to the user as part of their initial sign up process. @@ -72,14 +76,25 @@ def disable_software_token(user) private - def create_db_user(uid, email, provider) - Rails.logger.info "Creating User uid: #{uid}" + def create_db_user(uid, email, provider, password = nil, role = "applicant") + Rails.logger.info "Creating User uid: #{uid}, and UserRole: #{role}" + + user = nil + if Rails.env.development? && ENV["USE_DEVISE"].present? + user = User.create!( + uid: uid, + email: email, + password: password, + provider: provider, + ) + else + user = User.create!( + uid: uid, + email: email, + provider: provider, + ) + end - user = User.create!( - uid: uid, - email: email, - provider: provider, - ) user end diff --git a/app-rails/app/services/auth_service_factory.rb b/app-rails/app/services/auth_service_factory.rb new file mode 100644 index 000000000..3e1720c9c --- /dev/null +++ b/app-rails/app/services/auth_service_factory.rb @@ -0,0 +1,16 @@ +require "singleton" + +class AuthServiceFactory + include Singleton + + def initialize + @auth_service = + if ENV["AUTH_ADAPTER"] == "mock" + AuthService.new(Auth::MockAdapter.new) + else + AuthService.new(Auth::CognitoAdapter.new) + end + end + + attr_reader :auth_service +end diff --git a/app-rails/app/views/users/sessions/new.html.erb b/app-rails/app/views/users/sessions/new.html.erb index af1c65481..062160a2c 100644 --- a/app-rails/app/views/users/sessions/new.html.erb +++ b/app-rails/app/views/users/sessions/new.html.erb @@ -5,7 +5,7 @@ <%= t(".title") %> - <%= us_form_with model: @form, url: new_user_session_path, local: true do |f| %> + <%= us_form_with model: @form, url: new_user_session_path, data: { turbo: false }, local: true do |f| %> <%= f.honeypot_field %> <%= f.email_field :email, { autocomplete: "username" } %> diff --git a/app-rails/config/application.rb b/app-rails/config/application.rb index 0c9d277d3..876c78a0f 100644 --- a/app-rails/config/application.rb +++ b/app-rails/config/application.rb @@ -8,6 +8,7 @@ module TemplateApplicationRails class Application < Rails::Application + config.eager_load = true if Rails.env.development? # Internationalization I18n.available_locales = [ :"en", :"es-US" ] I18n.default_locale = :"en" @@ -49,5 +50,7 @@ class Application < Rails::Application # Show a 403 Forbidden error page when Pundit raises a NotAuthorizedError config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden + + config.auth_provider = ENV["USE_DEVISE"] == "true" ? :devise : :cognito end end diff --git a/app-rails/config/environments/development.rb b/app-rails/config/environments/development.rb index 2bc81a919..bea57c6bd 100644 --- a/app-rails/config/environments/development.rb +++ b/app-rails/config/environments/development.rb @@ -20,6 +20,8 @@ # Enable server timing. config.server_timing = true + config.auth_provider = :devise + # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. if Rails.root.join("tmp/caching-dev.txt").exist? diff --git a/app-rails/config/environments/test.rb b/app-rails/config/environments/test.rb index 7aa2887de..a60d43b07 100644 --- a/app-rails/config/environments/test.rb +++ b/app-rails/config/environments/test.rb @@ -8,6 +8,10 @@ # Custom setting: set the default url. Rails.application.default_url_options[:host] = "localhost" +Rails.application.configure do + config.auth_provider = :cognito +end + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/app-rails/config/initializers/devise.rb b/app-rails/config/initializers/devise.rb index b0dbf4deb..733b19ef4 100644 --- a/app-rails/config/initializers/devise.rb +++ b/app-rails/config/initializers/devise.rb @@ -1,3 +1,9 @@ +Aws.config.update( + region: "us-east-1", # Change this if needed + credentials: Aws::Credentials.new("fake_access_key", "fake_secret_key") +) + + # frozen_string_literal: true # Use this hook to configure devise mailer, warden hooks and so forth. @@ -11,6 +17,19 @@ # time the user will be asked for credentials again. Default is 30 minutes. config.timeout_in = 15.minutes + config.warden do |manager| + manager.failure_app = lambda do |env| + Devise::FailureApp.call(env) + end + end + + Rails.application.config.to_prepare do + if Rails.env.development? + User.connection + Devise.mappings[:user] + end + end + # ==> Navigation configuration # Lists the formats that should be treated as navigational. Formats like # :html should redirect to the sign in page when the user does not have @@ -20,7 +39,7 @@ # should add them to the navigational formats lists. # # The "*/*" below is required to match Internet Explorer requests. - # config.navigational_formats = ['*/*', :html, :turbo_stream] + config.navigational_formats = [ "*/*", :html, :turbo_stream ] # The default HTTP method used to sign out a resource. Default is :delete. config.sign_out_via = :delete @@ -125,10 +144,10 @@ # enable this with :database unless you are using a custom strategy. # The supported strategies are: # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false + config.http_authenticatable = true # If 401 status code should be returned for AJAX requests. True by default. - # config.http_authenticatable_on_xhr = true + config.http_authenticatable_on_xhr = true # The realm used in Http Basic Authentication. 'Application' by default. # config.http_authentication_realm = 'Application' diff --git a/app-rails/config/routes.rb b/app-rails/config/routes.rb index bfede2efc..5f6b90bc5 100644 --- a/app-rails/config/routes.rb +++ b/app-rails/config/routes.rb @@ -13,7 +13,10 @@ root "home#index" # Session management - devise_for :users, controllers: { sessions: "users/sessions" } + devise_for :users, controllers: { + sessions: "users/sessions", + registrations: "registrations" + } devise_scope :user do get "sessions/challenge" => "users/sessions#challenge", as: :session_challenge post "sessions/challenge" => "users/sessions#respond_to_challenge", as: :respond_to_session_challenge diff --git a/app-rails/db/migrate/20250414194640_add_local_devise_fields_to_users.rb b/app-rails/db/migrate/20250414194640_add_local_devise_fields_to_users.rb new file mode 100644 index 000000000..9ab5f0e3a --- /dev/null +++ b/app-rails/db/migrate/20250414194640_add_local_devise_fields_to_users.rb @@ -0,0 +1,18 @@ +class AddLocalDeviseFieldsToUsers < ActiveRecord::Migration[7.2] + def change + change_table :users do |t| + ## Database authenticatable + t.string :encrypted_password, null: true + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + end +end diff --git a/app-rails/db/schema.rb b/app-rails/db/schema.rb index a3f3dd1f2..9be610932 100644 --- a/app-rails/db/schema.rb +++ b/app-rails/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_10_140243) do +ActiveRecord::Schema[7.2].define(version: 2025_04_14_194640) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -49,6 +49,12 @@ t.integer "mfa_preference" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "encrypted_password" + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["uid"], name: "index_users_on_uid", unique: true end diff --git a/app-rails/local.env.example b/app-rails/local.env.example index b0e54bf77..e6e150eba 100644 --- a/app-rails/local.env.example +++ b/app-rails/local.env.example @@ -33,6 +33,8 @@ AWS_DEFAULT_REGION= COGNITO_USER_POOL_ID= COGNITO_CLIENT_ID= COGNITO_CLIENT_SECRET= +USE_DEVISE=true +AUTH_ADAPTER=mock ############################ # Database diff --git a/docs/app-rails/auth.md b/docs/app-rails/auth.md index 3d8d4ff93..a6c38b5d6 100644 --- a/docs/app-rails/auth.md +++ b/docs/app-rails/auth.md @@ -9,6 +9,7 @@ Authentication is the process of verifying the credentials of a user. We use AWS - Custom pages are built for the AWS Cognito flows (login, signup, forgot password, etc.). We aren't using the hosted UI that Cognito provides since we need more control over the UI and content. - [Devise](https://www.rubydoc.info/github/heartcombo/devise/main) and [Warden](https://github.com/wardencommunity/warden/wiki) facilitate auth and session management + ## Authorization Authorization is the process of determining whether a user has access to a specific resource. We use [Pundit](https://github.com/varvet/pundit) for authorization.