diff --git a/.gitignore b/.gitignore index 8a1b113..b965990 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ # Ignore master key for decrypting credentials and more. /config/master.key +/config/credentials/development.key +/config/credentials/production.key diff --git a/Gemfile b/Gemfile index 4bd47a3..3fc83b9 100644 --- a/Gemfile +++ b/Gemfile @@ -15,12 +15,13 @@ gem 'puma', '~> 4.1' gem 'grape' gem 'grape_on_rails_routes' +gem 'grape-route-helpers' gem 'grape-swagger' # Use Redis adapter to run Action Cable in production # gem 'redis', '~> 4.0' # Use Active Model has_secure_password -# gem 'bcrypt', '~> 3.1.7' +gem 'bcrypt', '~> 3.1.7' # Use Active Storage variant # gem 'image_processing', '~> 1.2' @@ -43,5 +44,9 @@ group :development do gem 'spring-watcher-listen', '~> 2.0.0' end +group :test do + gem 'factory_bot_rails' +end + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index bfac3d9..3fb3228 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -57,6 +57,7 @@ GEM tzinfo (~> 1.1) zeitwerk (~> 2.2, >= 2.2.2) ast (2.4.1) + bcrypt (3.1.15) bootsnap (1.4.8) msgpack (~> 1.0) builder (3.2.4) @@ -86,6 +87,11 @@ GEM dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) erubi (1.9.0) + factory_bot (6.1.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.1.0) + factory_bot (~> 6.1.0) + railties (>= 5.0.0) ffi (1.13.1) globalid (0.4.2) activesupport (>= 4.2.0) @@ -96,6 +102,10 @@ GEM mustermann-grape (~> 1.0.0) rack (>= 1.3.0) rack-accept + grape-route-helpers (2.1.0) + activesupport + grape (>= 0.16.0) + rake grape-swagger (1.2.1) grape (~> 1.3) grape_on_rails_routes (0.3.2) @@ -213,9 +223,12 @@ PLATFORMS ruby DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap (>= 1.4.2) byebug + factory_bot_rails grape + grape-route-helpers grape-swagger grape_on_rails_routes listen (~> 3.2) diff --git a/app/api/api/auth.rb b/app/api/api/auth.rb new file mode 100644 index 0000000..781fdbc --- /dev/null +++ b/app/api/api/auth.rb @@ -0,0 +1,24 @@ +module API + class Auth < Grape::API + namespace :session do + desc 'Acquires a session cookie from a username and password' + params do + requires :username, type: String + requires :password, type: String + end + post auth: false do + session.destroy + + user = User.find_by(email: params[:username]) + + error! :not_found, 404 unless user.try(:authenticate, params[:password]) + + session[:user_id] = user.id + cookies[:_csrf_token] = session[:csrf_token] = SecureRandom.base64(32) + + status :ok + {} + end + end + end +end diff --git a/app/api/api_helpers.rb b/app/api/api_helpers.rb new file mode 100644 index 0000000..44131cf --- /dev/null +++ b/app/api/api_helpers.rb @@ -0,0 +1,7 @@ +module APIHelpers + extend Grape::API::Helpers + + def session + env['rack.session'] + end +end diff --git a/app/api/api_routes.rb b/app/api/api_routes.rb index e07fa6b..fdd1444 100644 --- a/app/api/api_routes.rb +++ b/app/api/api_routes.rb @@ -1,3 +1,8 @@ class APIRoutes < Grape::API + use ActionDispatch::Session::CookieStore, key: '_sbhacks_session' + helpers APIHelpers format :json + auth :from_session + + mount API::Auth end diff --git a/app/lib/auth/session_auth_middleware.rb b/app/lib/auth/session_auth_middleware.rb new file mode 100644 index 0000000..5684d1d --- /dev/null +++ b/app/lib/auth/session_auth_middleware.rb @@ -0,0 +1,23 @@ +module Auth + class SessionAuthMiddleware < Grape::Middleware::Auth::Base + def call(env) + self.env = env + + if context.route.options[:auth] != false + throw(:error, status: 401, message: 'Unauthorized') unless context.session[:user_id] + throw(:error, status: 403, message: 'Forbidden') unless verified_request? + end + + @app.call(env) + end + + private + + def verified_request? + return true if Rails.env.development? && env['HTTP_X_IGNORE_CSRF'] + + method = context.route.options[:method] + method == 'GET' || method == 'HEAD' || context.session[:csrf_token] == env['HTTP_X_CSRF_TOKEN'] + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..24262df --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,10 @@ +class User < ApplicationRecord + has_secure_password + + validates :name, presence: true + validates :email, format: { + with: URI::MailTo::EMAIL_REGEXP, + message: 'is not a valid email address' + } + validates :password, length: { in: 8..72 } +end diff --git a/config/application.rb b/config/application.rb index 601e6f2..0d526f7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -33,5 +33,9 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + + config.generators do |g| + g.test_framework :test_unit, fixture: false + end end end diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc new file mode 100644 index 0000000..3958a5f --- /dev/null +++ b/config/credentials/development.yml.enc @@ -0,0 +1 @@ +hAbLzw==--Izatsmxe7g0ccMzf--NlgK4PY2R7zLYMwSPx4Yeg== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 0000000..a2785b1 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +DClZHkmJZpADs4292n3axT+PHxN5YgAXk7CUqJEiQlW9+BfPJjeCH6IgLVI13ERsPqjE5HLwPwkewSbjw2pzwTDoZ6IbLbJFCCE5TSMnDvfOo8mjzaFoqMyFSouUN5BRinqJlbm7tLIuWauoH5UjE2LmWN6n7hr8iO6dchDzyWyhDJ+sH2xqaiDrlIgTeJGbxhSEOg4dHw==--I5PVRTkkRL16keFX--RS9Oq7j9pwZalWyU/Q26lw== \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 0cb2424..8969127 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -3,6 +3,10 @@ # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! +# Tell Ruby to shut up about deprecation warnings during tests +# https://fuzzyblog.io/blog/rails/2020/01/28/turning-off-ruby-deprecation-warnings-when-running-tests.html +$VERBOSE = nil + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/config/initializers/cookies.rb b/config/initializers/cookies.rb new file mode 100644 index 0000000..9aa8b20 --- /dev/null +++ b/config/initializers/cookies.rb @@ -0,0 +1,3 @@ +# Be sure to restart your server when you modify this file. + +Rails.application.config.middleware.use ActionDispatch::Cookies diff --git a/config/initializers/grape.rb b/config/initializers/grape.rb new file mode 100644 index 0000000..a5c09bb --- /dev/null +++ b/config/initializers/grape.rb @@ -0,0 +1,5 @@ +# Be sure to restart your server when you modify this file. + +Rails.configuration.to_prepare do + Grape::Middleware::Auth::Strategies.add(:from_session, Auth::SessionAuthMiddleware) +end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..63d593a --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,3 @@ +# Be sure to restart your server when you modify this file. + +Rails.application.config.middleware.use ActionDispatch::Session::CookieStore, key: '_sbhacks_session' diff --git a/db/migrate/20200824035326_create_users.rb b/db/migrate/20200824035326_create_users.rb new file mode 100644 index 0000000..5987b7c --- /dev/null +++ b/db/migrate/20200824035326_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[6.0] + def change + create_table :users do |t| + t.string :name, nil: false + t.string :email, index: { unique: true }, nil: false + t.string :password_digest, nil: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b10373b..147138a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,18 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 0) do +ActiveRecord::Schema.define(version: 2020_08_24_035326) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "users", force: :cascade do |t| + t.string "name" + t.string "email" + t.string "password_digest" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + end diff --git a/db/seeds.rb b/db/seeds.rb index 1beea2a..4468e03 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,7 +1,5 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) -# Character.create(name: 'Luke', movie: movies.first) +User.create!( + name: 'SBHacks Admin', + email: 'development@sbhacks.com', + password: 'password' +) diff --git a/test/api/auth_test.rb b/test/api/auth_test.rb new file mode 100644 index 0000000..a6174a7 --- /dev/null +++ b/test/api/auth_test.rb @@ -0,0 +1,17 @@ +require 'test_helper' + +class AuthTest < ActionDispatch::IntegrationTest + test 'signing in sets the session id' do + user = sign_in_as(:user) + + assert_equal user.id, @request.session[:user_id] + end + + test 'signing in with invalid credentials clears the session' do + sign_in_as(:user) + + post session_path, params: { username: 'nonexistent user', password: '' } + + assert_nil @request.session[:user_id] + end +end diff --git a/test/factories/user.rb b/test/factories/user.rb new file mode 100644 index 0000000..7528474 --- /dev/null +++ b/test/factories/user.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :user do + name { 'Akshay Heda' } + sequence :email do |n| + "akshay.#{n}@example.com" + end + password { 'ilovesbhacks' } + end +end diff --git a/test/fixtures/.keep b/test/fixtures/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..fd81ae9 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' + +class UserTest < ActiveSupport::TestCase + test 'factory is valid' do + assert_predicate build(:user), :valid? + end + + test 'requires a name' do + user = build(:user) + user.name = nil + assert_not_predicate user, :valid? + + user.name = '' + assert_not_predicate user, :valid? + end + + test 'requires a valid email address' do + user = build(:user) + user.email = 'not an email' + assert_not_predicate user, :valid? + end + + test 'requires a password' do + user = build(:user) + user.password = nil + assert_not_predicate user, :valid? + + user.password = 'short' + assert_not_predicate user, :valid? + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1b7300e..105832a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,15 +1,16 @@ ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' require 'rails/test_help' +require 'test_helpers/sign_in_helper' module ActiveSupport class TestCase + include GrapeRouteHelpers::NamedRouteMatcher + include FactoryBot::Syntax::Methods + include SignInHelper # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. - fixtures :all - # Add more helper methods to be used by all tests here... end end diff --git a/test/test_helpers/sign_in_helper.rb b/test/test_helpers/sign_in_helper.rb new file mode 100644 index 0000000..675e7db --- /dev/null +++ b/test/test_helpers/sign_in_helper.rb @@ -0,0 +1,9 @@ +module SignInHelper + def sign_in_as(user) + user = create(user) if user.is_a? Symbol + + post session_path, params: { username: user.email, password: user.password } + + user + end +end