From 16fadfd52974a77d70973ca64f12e397f868a08a Mon Sep 17 00:00:00 2001 From: Jason Jordan Date: Mon, 18 May 2026 13:17:28 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 42 +++ .env.example | 1 + .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 39 ++ .gitignore | 30 ++ .rspec | 1 + .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 66 ++++ Gemfile | 56 +++ Gemfile.lock | 346 ++++++++++++++++++ LICENSE | 21 ++ README.md | 247 +++++++++++++ Rakefile | 6 + app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 4 + app/controllers/api/v1/admin_controller.rb | 23 ++ app/controllers/api/v1/auth_controller.rb | 87 +++++ app/controllers/api/v1/profile_controller.rb | 16 + .../api/v1/protected_controller.rb | 14 + app/controllers/application_controller.rb | 2 + app/controllers/concerns/.keep | 0 app/controllers/concerns/authorize_request.rb | 33 ++ app/controllers/concerns/authorize_role.rb | 29 ++ app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/blacklisted_token.rb | 16 + app/models/concerns/.keep | 0 app/models/refresh_token.rb | 30 ++ app/models/user.rb | 17 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + bin/brakeman | 7 + bin/bundle | 109 ++++++ bin/docker-entrypoint | 13 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 37 ++ config.ru | 6 + config/application.rb | 45 +++ config/boot.rb | 4 + config/cable.yml | 10 + config/credentials.yml.enc | 1 + config/database.yml | 85 +++++ config/environment.rb | 5 + config/environments/development.rb | 75 ++++ config/environments/production.rb | 98 +++++ config/environments/test.rb | 67 ++++ config/initializers/cors.rb | 16 + config/initializers/devise.rb | 313 ++++++++++++++++ .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/initializers/rack_attack.rb | 33 ++ config/initializers/rswag_api.rb | 13 + config/initializers/rswag_ui.rb | 15 + config/locales/devise.en.yml | 65 ++++ config/locales/en.yml | 31 ++ config/puma.rb | 34 ++ config/routes.rb | 25 ++ config/storage.yml | 34 ++ .../20250613174349_devise_create_users.rb | 44 +++ ...0260128000001_create_blacklisted_tokens.rb | 14 + .../20260128000002_create_refresh_tokens.rb | 15 + .../20260128000003_add_role_to_users.rb | 6 + db/schema.rb | 56 +++ db/seeds.rb | 9 + lib/json_web_token.rb | 32 ++ lib/tasks/.keep | 0 log/.keep | 0 public/robots.txt | 1 + spec/models/blacklisted_token_spec.rb | 55 +++ spec/models/refresh_token_spec.rb | 71 ++++ spec/rails_helper.rb | 72 ++++ spec/requests/api/v1/admin_spec.rb | 42 +++ spec/requests/api/v1/auths_spec.rb | 37 ++ spec/requests/api/v1/logout_spec.rb | 55 +++ spec/requests/api/v1/profile_spec.rb | 23 ++ spec/requests/api/v1/rack_attack_spec.rb | 29 ++ spec/requests/api/v1/refresh_spec.rb | 55 +++ spec/requests/api/v1/signups_spec.rb | 51 +++ spec/spec_helper.rb | 94 +++++ spec/swagger_helper.rb | 53 +++ storage/.keep | 0 swagger/v1/swagger.yaml | 138 +++++++ tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 91 files changed, 3321 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/api/v1/admin_controller.rb create mode 100644 app/controllers/api/v1/auth_controller.rb create mode 100644 app/controllers/api/v1/profile_controller.rb create mode 100644 app/controllers/api/v1/protected_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/authorize_request.rb create mode 100644 app/controllers/concerns/authorize_role.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/blacklisted_token.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/refresh_token.rb create mode 100644 app/models/user.rb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100755 bin/brakeman create mode 100755 bin/bundle create mode 100755 bin/docker-entrypoint create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cable.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/cors.rb create mode 100644 config/initializers/devise.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/rack_attack.rb create mode 100644 config/initializers/rswag_api.rb create mode 100644 config/initializers/rswag_ui.rb create mode 100644 config/locales/devise.en.yml create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/migrate/20250613174349_devise_create_users.rb create mode 100644 db/migrate/20260128000001_create_blacklisted_tokens.rb create mode 100644 db/migrate/20260128000002_create_refresh_tokens.rb create mode 100644 db/migrate/20260128000003_add_role_to_users.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/json_web_token.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/robots.txt create mode 100644 spec/models/blacklisted_token_spec.rb create mode 100644 spec/models/refresh_token_spec.rb create mode 100644 spec/rails_helper.rb create mode 100644 spec/requests/api/v1/admin_spec.rb create mode 100644 spec/requests/api/v1/auths_spec.rb create mode 100644 spec/requests/api/v1/logout_spec.rb create mode 100644 spec/requests/api/v1/profile_spec.rb create mode 100644 spec/requests/api/v1/rack_attack_spec.rb create mode 100644 spec/requests/api/v1/refresh_spec.rb create mode 100644 spec/requests/api/v1/signups_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/swagger_helper.rb create mode 100644 storage/.keep create mode 100644 swagger/v1/swagger.yaml create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f5a804 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore CI service files. +/.github + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..698461d --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +JWT_SECRET_KEY=your_super_secret_key_here diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb0f4ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1532a07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +# Ignore master key for decrypting credentials and more. +/config/master.key +.env diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..bea438e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc288c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# syntax = docker/dockerfile:1 + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t my-app . +# docker run -d -p 80:80 -p 443:443 --name my-app -e RAILS_MASTER_KEY= my-app + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.3.1 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + + + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD ["./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..375d402 --- /dev/null +++ b/Gemfile @@ -0,0 +1,56 @@ +source "https://rubygems.org" + +ruby "3.3.1" +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 7.2.2", ">= 7.2.2.1" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +# gem "jbuilder" +# Use Redis adapter to run Action Cable in production +# gem "redis", ">= 4.0.1" + +# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] +# gem "kredis" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible +gem "rack-cors" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + + + +gem "devise", "~> 4.9" + +gem "jwt", "~> 2.10" + +gem "dotenv-rails", groups: [ :development, :test ] + +gem "rspec-rails", "~> 8.0" + +gem "rswag", "~> 2.16" + +gem "rack-attack", "~> 6.7" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6f117f2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,346 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) + mail (>= 2.8.0) + actionmailer (7.2.2.1) + actionpack (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activesupport (= 7.2.2.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.2.1) + actionview (= 7.2.2.1) + activesupport (= 7.2.2.1) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.2) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.2.1) + actionpack (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.2.2.1) + activesupport (= 7.2.2.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.2.2.1) + activesupport (= 7.2.2.1) + globalid (>= 0.3.6) + activemodel (7.2.2.1) + activesupport (= 7.2.2.1) + activerecord (7.2.2.1) + activemodel (= 7.2.2.1) + activesupport (= 7.2.2.1) + timeout (>= 0.4.0) + activestorage (7.2.2.1) + actionpack (= 7.2.2.1) + activejob (= 7.2.2.1) + activerecord (= 7.2.2.1) + activesupport (= 7.2.2.1) + marcel (~> 1.0) + activesupport (7.2.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.20) + benchmark (0.4.1) + bigdecimal (3.2.2) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.0.2) + racc + builder (3.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.3) + crass (1.0.6) + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + diff-lcs (1.6.2) + dotenv (3.1.8) + dotenv-rails (3.1.8) + dotenv (= 3.1.8) + railties (>= 6.1) + drb (2.2.3) + erb (5.0.1) + erubi (1.13.1) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + io-console (0.8.0) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.12.2) + json-schema (5.1.1) + addressable (~> 2.8) + bigdecimal (~> 3.1) + jwt (2.10.1) + base64 + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + mini_mime (1.1.5) + minitest (5.25.5) + msgpack (1.8.0) + net-imap (0.5.8) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.4) + nokogiri (1.18.8-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.8-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.8-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.8-x86_64-linux-musl) + racc (~> 1.4) + orm_adapter (0.5.0) + parallel (1.27.0) + parser (3.3.8.0) + ast (~> 2.4.1) + racc + pg (1.5.9) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (6.6.0) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.1.16) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (7.2.2.1) + actioncable (= 7.2.2.1) + actionmailbox (= 7.2.2.1) + actionmailer (= 7.2.2.1) + actionpack (= 7.2.2.1) + actiontext (= 7.2.2.1) + actionview (= 7.2.2.1) + activejob (= 7.2.2.1) + activemodel (= 7.2.2.1) + activerecord (= 7.2.2.1) + activestorage (= 7.2.2.1) + activesupport (= 7.2.2.1) + bundler (>= 1.15.0) + railties (= 7.2.2.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.2.2.1) + actionpack (= 7.2.2.1) + activesupport (= 7.2.2.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.14.0) + erb + psych (>= 4.0.0) + regexp_parser (2.10.0) + reline (0.6.1) + io-console (~> 0.5) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) + rspec-core (3.13.4) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.0) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.4) + rswag (2.16.0) + rswag-api (= 2.16.0) + rswag-specs (= 2.16.0) + rswag-ui (= 2.16.0) + rswag-api (2.16.0) + activesupport (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rswag-specs (2.16.0) + activesupport (>= 5.2, < 8.1) + json-schema (>= 2.2, < 6.0) + railties (>= 5.2, < 8.1) + rspec-core (>= 2.14) + rswag-ui (2.16.0) + actionpack (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rubocop (1.76.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.45.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.45.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.32.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + stringio (3.1.7) + thor (1.3.2) + timeout (0.4.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + useragent (0.16.11) + warden (1.2.9) + rack (>= 2.0.9) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bootsnap + brakeman + debug + devise (~> 4.9) + dotenv-rails + jwt (~> 2.10) + pg (~> 1.1) + puma (>= 5.0) + rack-attack (~> 6.7) + rack-cors + rails (~> 7.2.2, >= 7.2.2.1) + rspec-rails (~> 8.0) + rswag (~> 2.16) + rubocop-rails-omakase + tzinfo-data + +RUBY VERSION + ruby 3.3.1p55 + +BUNDLED WITH + 2.5.9 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e09555a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 rustam-tolipov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3a2921 --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +![Ruby](https://img.shields.io/badge/ruby-3.3.1-red) +![Rails](https://img.shields.io/badge/rails-7.2.2.1-red) +![RSpec](https://img.shields.io/badge/tested%20with-rspec-blue) +![Swagger](https://img.shields.io/badge/docs-swagger-yellow) + +# rails-api-auth-template + +> fast start rails api auth setup with devise + jwt + +## πŸš€ what is this? + +this is a rails 7.2 api-only template with jwt authentication using devise. +you can skip the boring setup and jump straight into building cool stuff. + +![screenshot](https://github.com/user-attachments/assets/278b23fd-46d0-4085-9170-45a8da140e6f) + +## 🧠 why tho? + +because every time you start a new project, you forget one step. +or five. +or all of them. + +this template saves you from: + +* repeating the same setup 900 times +* googling β€œrails api jwt devise setup” again +* crying over untracked .env files + +## πŸ”§ stack + +* ruby 3.3.1 +* rails 7.2.2.1 (api-only) +* devise (user auth) +* jwt (hand-rolled, no devise-jwt dependency) +* rspec + rswag (for testing + swagger docs) +* dotenv (for managing secrets) +* rack-cors (so your frontend doesn’t scream) +* rack-attack (rate limiting β€” no room for brute force bots) + +## πŸ§ͺ how to use this as a template + +1. click the green **β€œUse this template”** button on the top-right +2. name your new repo (e.g. `my-next-api`) +3. clone it +4. run the setup: + +```bash +bundle install +cp .env.example .env +rails db:create db:migrate +``` + +## βš™οΈ or setup as a starter project + +```bash +git clone https://github.com/yourname/rails-api-auth-template.git +cd rails-api-auth-template +bundle install +yarn install # (if needed) +cp .env.example .env +rails db:create db:migrate +``` + +## πŸ” auth flow + +### signup + +```bash +POST /api/v1/signup +{ + "email": "bob@random.com", + "password": "123456", + "password_confirmation": "123456" +} +``` + +returns access token + refresh token + user json + +### login + +```bash +POST /api/v1/login +{ + "email": "bob@random.com", + "password": "123456" +} +``` + +returns access token + refresh token + user json + +### refresh + +```bash +POST /api/v1/refresh +{ + "refresh_token": "" +} +``` + +returns a new access token (keeps you logged in without re-entering credentials) + +### profile (protected) + +```bash +GET /api/v1/profile +Authorization: Bearer +``` + +returns current user + +### logout + +```bash +POST /api/v1/logout +Authorization: Bearer +``` + +blacklists the current token (real logout, token becomes invalid) + +## πŸ‘₯ role-based authorization + +users have roles: `user` (default), `moderator`, or `admin` + +### example: admin-only endpoint + +```bash +GET /api/v1/admin/dashboard +Authorization: Bearer +``` + +returns admin dashboard data (403 forbidden for non-admins) + +### using roles in your controllers + +```ruby +class MyController < ApplicationController + include AuthorizeRequest + include AuthorizeRole + + before_action :require_admin # only admins + # or + before_action :require_moderator # admins + moderators +end +``` + +## πŸ”’ security features + +* **no secret fallbacks**: JWT_SECRET_KEY must be set (crashes if missing) +* **token blacklisting**: logout actually invalidates tokens +* **refresh tokens**: short-lived access tokens (1 hour) + long-lived refresh tokens (7 days) +* **rate limiting**: login, signup, and refresh endpoints are throttled +* **JTI tracking**: every token has a unique identifier for precise control +* **automatic cleanup**: expired tokens can be cleaned via scheduled jobs + +## πŸ“– swagger ui + +run: + +```bash +RAILS_ENV=test bundle exec rake rswag:specs:swaggerize +rails s +``` + +open [http://localhost:3000/api-docs](http://localhost:3000/api-docs) + +## πŸ§ͺ test + +```bash +rspec +``` + +## πŸš€ production considerations + +### cleanup jobs + +add these to your scheduled jobs (sidekiq, cron, etc): + +```ruby +# clean up expired blacklisted tokens +BlacklistedToken.cleanup_expired + +# clean up old refresh tokens +RefreshToken.cleanup_old_tokens +``` + +### environment variables + +make sure to set these in production: + +```bash +JWT_SECRET_KEY=your_super_secret_key_here_use_rails_secret +DATABASE_URL=your_database_url +REDIS_URL=your_redis_url (optional, for rack-attack) +``` + +### database indexes + +migrations include proper indexes for performance: +* `blacklisted_tokens.jti` (unique) +* `blacklisted_tokens.exp` +* `refresh_tokens.token` (unique) +* `refresh_tokens.user_id + revoked` +* `users.role` + +## 🀝 contribute + +open to contributions, improvements, or just saying hi. +open issues or pull requests. + +## ✨ features + +* βœ… JWT authentication with secure token generation (includes JTI for tracking) +* βœ… Token blacklisting for real logout (tokens are invalidated on logout) +* βœ… Refresh tokens (7-day expiry, keeps users logged in securely) +* βœ… Role-based authorization (user, moderator, admin roles) +* βœ… Rate limiting with Rack::Attack (prevents brute force attacks) +* βœ… Comprehensive test coverage with RSpec +* βœ… Swagger API documentation via rswag +* βœ… Security best practices (no fallback secrets, proper validation) + +## 🧼 todo + +* add email confirmation for signup πŸ“§ +* add password reset functionality πŸ”‘ +* add remember me token (long-lived sessions) πŸ’Ύ +* add oauth providers (google, github, etc) πŸ”— + +## πŸ“’ shoutout + +built to help devs like you (and me) avoid setup fatigue. +feel free to fork, star, share, or improve. + +## ⚠️ disclaimer + +this template includes production-grade features like token blacklisting, refresh tokens, and role-based auth. +however, you should still: +* review security settings for your specific use case +* set up proper monitoring and logging +* configure ssl/tls in production +* add email confirmation if needed +* implement proper error tracking + +use responsibly and test thoroughly before deploying. + +--- + +made with β™₯ by rustam diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..0ff5442 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/controllers/api/v1/admin_controller.rb b/app/controllers/api/v1/admin_controller.rb new file mode 100644 index 0000000..5a56fcd --- /dev/null +++ b/app/controllers/api/v1/admin_controller.rb @@ -0,0 +1,23 @@ +module Api + module V1 + class AdminController < ApplicationController + include AuthorizeRequest + include AuthorizeRole + + before_action :require_admin + + # example admin-only endpoint + # GET /api/v1/admin/dashboard + def dashboard + render json: { + message: "Welcome to admin dashboard", + stats: { + total_users: User.count, + total_admins: User.admin.count, + total_moderators: User.moderator.count + } + }, status: :ok + end + end + end +end diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb new file mode 100644 index 0000000..5048aaa --- /dev/null +++ b/app/controllers/api/v1/auth_controller.rb @@ -0,0 +1,87 @@ +module Api + module V1 + class AuthController < ApplicationController + include AuthorizeRequest + skip_before_action :authorize_request, only: %i[signup login refresh] + + # post /signup -> signup user and return jwt + refresh token + # I used strong params so no sql injection here (rails got your back) + def signup + user = User.new(user_params) + if user.save + tokens = generate_tokens(user) + render json: { **tokens, user: user.as_json(only: %i[id email role]) }, status: :created + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + + # post /login -> login with email & password (keep it simple) + # if it matches, it gives you both access token and refresh token + def login + user = User.find_by(email: params[:email]) + if user&.valid_password?(params[:password]) + tokens = generate_tokens(user) + render json: { **tokens, user: user.as_json(only: %i[id email role]) }, status: :ok + else + render json: { error: "Invalid email or password" }, status: :unauthorized + end + end + + # post /refresh -> exchange refresh token for new access token + # keeps users logged in without re-entering credentials + def refresh + refresh_token = RefreshToken.find_by(token: params[:refresh_token]) + + if refresh_token&.active? + user = refresh_token.user + access_token = JsonWebToken.encode(user_id: user.id) + render json: { access_token:, user: user.as_json(only: %i[id email role]) }, status: :ok + else + render json: { error: "Invalid or expired refresh token" }, status: :unauthorized + end + end + + # post /logout -> NOW with real token blacklisting + # adds the current token to blacklist so it can't be used again + # authorize_request ensures @current_user and token are present + def logout + header = request.headers["Authorization"] + token = header.split(" ").last if header + + decoded = JsonWebToken.decode(token) + BlacklistedToken.create!( + jti: decoded[:jti], + user_id: decoded[:user_id], + exp: Time.at(decoded[:exp]) + ) + + # also revoke all refresh tokens for this user + @current_user.refresh_tokens.update_all(revoked: true) + + render json: { message: "Successfully logged out. Token blacklisted." }, status: :ok + rescue StandardError => e + render json: { error: "Logout failed: #{e.message}" }, status: :unprocessable_entity + end + + private + + # only allow what we actually need. nothing fancy, nothing extra. not need for require + def user_params + params.permit(:email, :password, :password_confirmation) + end + + # generate both access and refresh tokens + def generate_tokens(user) + access_token = JsonWebToken.encode(user_id: user.id) + refresh_token = user.refresh_tokens.create! + + { + access_token: access_token, + refresh_token: refresh_token.token, + expires_in: 1.hour.to_i + } + end + end + end +end diff --git a/app/controllers/api/v1/profile_controller.rb b/app/controllers/api/v1/profile_controller.rb new file mode 100644 index 0000000..7d74dfe --- /dev/null +++ b/app/controllers/api/v1/profile_controller.rb @@ -0,0 +1,16 @@ +module Api + module V1 + class ProfileController < ApplicationController + include AuthorizeRequest + + # get /profile + # this is for getting current user who logged in + def show + render json: { + id: @current_user.id, + email: @current_user.email + }, status: :ok + end + end + end +end diff --git a/app/controllers/api/v1/protected_controller.rb b/app/controllers/api/v1/protected_controller.rb new file mode 100644 index 0000000..574d02a --- /dev/null +++ b/app/controllers/api/v1/protected_controller.rb @@ -0,0 +1,14 @@ +module Api + module V1 + class ProtectedController < ApplicationController + include AuthorizeRequest + + def index + render json: { + message: "you are free to use this buddy", + user: @current_user.as_json(only: %(id email)) + } + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..4ac8823 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::API +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/concerns/authorize_request.rb b/app/controllers/concerns/authorize_request.rb new file mode 100644 index 0000000..0ecae68 --- /dev/null +++ b/app/controllers/concerns/authorize_request.rb @@ -0,0 +1,33 @@ +# this concern checks the token and finds the user +# if token is invalid or missing, we block the request +# usage: just add `before_action :authorize_request` in any controller you wanna protect +# I keep things simple, if you want to make it better it's you choice + +module AuthorizeRequest + extend ActiveSupport::Concern + + included do + before_action :authorize_request + end + + private + + def authorize_request + header = request.headers["Authorization"] + token = header.split(" ").last if header + + begin + decoded = JsonWebToken.decode(token) + + # check if token is blacklisted (logged out) + if BlacklistedToken.blacklisted?(decoded[:jti]) + render json: { error: "Token has been revoked" }, status: :unauthorized + return + end + + @current_user = User.find(decoded[:user_id]) + rescue ActiveRecord::RecordNotFound, StandardError => e + render json: { error: "unauthorized: #{e.message}" }, status: :unauthorized + end + end +end diff --git a/app/controllers/concerns/authorize_role.rb b/app/controllers/concerns/authorize_role.rb new file mode 100644 index 0000000..2faf4a9 --- /dev/null +++ b/app/controllers/concerns/authorize_role.rb @@ -0,0 +1,29 @@ +# this concern provides role-based authorization +# usage: add `before_action :require_admin` in controllers that need admin access +# or use `authorize_role!(:admin, :moderator)` to check multiple roles + +module AuthorizeRole + extend ActiveSupport::Concern + + private + + # check if current user has any of the specified roles + def authorize_role!(*roles) + unless @current_user && roles.map(&:to_s).include?(@current_user.role) + render json: { error: "Forbidden: insufficient permissions" }, status: :forbidden + end + end + + # helper methods for specific roles + def require_admin + authorize_role!(:admin) + end + + def require_moderator + authorize_role!(:admin, :moderator) + end + + def require_user + authorize_role!(:user, :moderator, :admin) + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/blacklisted_token.rb b/app/models/blacklisted_token.rb new file mode 100644 index 0000000..89b8944 --- /dev/null +++ b/app/models/blacklisted_token.rb @@ -0,0 +1,16 @@ +class BlacklistedToken < ApplicationRecord + belongs_to :user + + validates :jti, presence: true, uniqueness: true + validates :exp, presence: true + + # check if a token is blacklisted + def self.blacklisted?(jti) + exists?(jti: jti) + end + + # cleanup expired tokens (run this via a scheduled job) + def self.cleanup_expired + where("exp < ?", Time.current).delete_all + end +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/refresh_token.rb b/app/models/refresh_token.rb new file mode 100644 index 0000000..333b6fc --- /dev/null +++ b/app/models/refresh_token.rb @@ -0,0 +1,30 @@ +class RefreshToken < ApplicationRecord + belongs_to :user + + validates :token, presence: true, uniqueness: true + validates :expires_at, presence: true + + before_validation :generate_token, on: :create + + # check if token is still active (not revoked and not expired) + def active? + !revoked && expires_at > Time.current + end + + # revoke this token + def revoke! + update!(revoked: true) + end + + # generate a secure random token + def generate_token + self.token ||= SecureRandom.urlsafe_base64(32) + self.expires_at ||= 7.days.from_now + end + + # cleanup expired or old revoked tokens (run via scheduled job) + # deletes tokens that are either expired OR (revoked AND old) + def self.cleanup_old_tokens + where("expires_at < ? OR (revoked = ? AND created_at < ?)", 30.days.ago, true, 30.days.ago).delete_all + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..b7fba6a --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,17 @@ +class User < ApplicationRecord + # Include default devise modules. Others available are: + # :recoverable, :rememberable, :confirmable, :lockable, :timeoutable, :trackable and :omniauthable + devise :database_authenticatable, :registerable, + :validatable + + # associations + has_many :blacklisted_tokens, dependent: :destroy + has_many :refresh_tokens, dependent: :destroy + + # role-based authorization + enum :role, { user: 0, admin: 1, moderator: 2 } + + # validations so your db doesn't turn into a trash can + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, presence: true, length: { minimum: 6 }, if: -> { new_record? || !password.nil? } +end diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..840d093 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,13 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then + export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)" +fi + +# If running the rails server then create or migrate existing database +if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..40330c0 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..d2ba44e --- /dev/null +++ b/bin/setup @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) +APP_NAME = "rails-api-auth-template" + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" + + # puts "\n== Configuring puma-dev ==" + # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" + # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..1eb0548 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,45 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_mailbox/engine" +require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module RailsApiAuthTemplate + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.2 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Only loads a smaller set of middleware suitable for API only apps. + # 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.middleware.use Rack::Attack + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..b2af033 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: rails_api_auth_template_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..3c1355d --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +C8cNNcZqYebjAw8BulNRzb1XoVSYiYDBTAqD9rRT8Ohj4w5qBDK5fBsInnuXkRoKW1OMShMpTI2EM62bTojpWv6ngi3uXLBco44HGihMOdZG7MygDpGxABUHY6uCmOHvxDIvzJl9H7llT/zwIYvaR9RfIgs5BkoKyQL/vH3pTfGLBj/WzamoIORypdCXI3TjRH4gxg4+0xXfoMksbHL6m1LkvwYTPEF2QnHvkdDtuJJ9HbTSdZUKghxMxSJCt1HxPUY6q2kOaiK1YLuUr7zS7lUnU90DJKEUx+rziuJzf3/jDZS4QQ302TZmH8T4+3zqAI4fbxXhpvs+rc4YmQAFklBFU7Tg6l14OfVIKrXOJwozlqR4k6wUJscQgWA/D31CD+iBxD6lb8HtQYRL4lje6bOmSppv--VfiVjtSC35zIsQYQ--OMqv4NfZlaEM+Tuw7Zg1Rg== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..32fab38 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,85 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + + +development: + <<: *default + database: rails_api_auth_template_development + + # The specified database role being used to connect to PostgreSQL. + # To create additional roles in PostgreSQL see `$ createuser --help`. + # When left blank, PostgreSQL will use the default role. This is + # the same name as the operating system user running Rails. + #username: rails_api_auth_template + + # The password associated with the PostgreSQL role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $user,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: rails_api_auth_template_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + <<: *default + database: rails_api_auth_template_production + username: rails_api_auth_template + password: <%= ENV["RAILS_API_AUTH_TEMPLATE_DATABASE_PASSWORD"] %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..98128ff --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,75 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.cache_store = :memory_store + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. + config.action_mailer.perform_caching = false + + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..02c59e3 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,98 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + + # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment + # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new(STDOUT) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [ :request_id ] + + # "info" includes generic and useful information about system operation, but avoids logging too much + # information to avoid inadvertent exposure of personally identifiable information (PII). If you + # want to log everything, set the level to "debug". + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "rails_api_auth_template_production" + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..0c616a1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,67 @@ +require "active_support/core_ext/integer/time" + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# 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! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Disable caching for Action Mailer templates even if Action Controller + # caching is enabled. + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Unlike controllers, the mailer instance doesn't have any context about the + # incoming request so you'll need to provide the :host parameter yourself. + config.action_mailer.default_url_options = { host: "www.example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..835d425 --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests. + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins "*" + + resource "*", + headers: :any, + methods: [ :get, :post, :put, :patch, :delete, :options, :head ] + end +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 0000000..574afd8 --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = '48e5fef13ecab7262e95624bd16f02e6a4ce11f2d44f1e18851e3e046535a6b6c233c94d1eb28e62655a12a63acb9ead3426ca1cb36753fa33f2970cd5377c3b' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require "devise/orm/active_record" + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [ :email ] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [ :email ] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # 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 + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + # config.paranoid = true + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [ :http_auth ] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = '707981717305b01fb766b9fa75e6a7dd0246ea9b359cd99c110bd42fe488ca9d4e1b5e6ef52d583c3e30fc3db65571ce5370397e8dd1516c1331c4bdd7518b03' + + # Send a notification to the original email when the user's email is changed. + # config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + # config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + # config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + # config.remember_for = 2.weeks + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + # config.extend_remember_period = false + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + # config.rememberable_options = {} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 6..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + # config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + # config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + # config.maximum_attempts = 20 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + # config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + # config.scoped_views = false + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> 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 + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + config.navigational_formats = [] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :delete + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |manager| + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_entity + config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c010b83 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 0000000..1e7382e --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,33 @@ +class Rack::Attack + # i added this to test r.a in dev mode + # this tries to use MemoryStore cache if Redis is not present + # kudos for this guy: honeybadger.io/blog/rails-api-rack-attack/ + if !ENV["REDIS_URL"] || Rails.env.test? + cache.store = ActiveSupport::Cache::MemoryStore.new + end + + # this is for limiting login attempts to 5 reqs every 20 secs per ip + # and of course it's for preventing brute force ;) + throttle("logins/ip", limit: 5, period: 20.seconds) do |req| + req.ip if req.path == "/api/v1/login" && req.post? + end + + # same thing for signup as well + throttle("signups/ip", limit: 5, period: 60.seconds) do |req| + req.ip if req.path == "/api/v1/signup" && req.post? + end + + # limit refresh token attempts to prevent abuse + throttle("refresh/ip", limit: 10, period: 60.seconds) do |req| + req.ip if req.path == "/api/v1/refresh" && req.post? + end + + # why not tell them politely if they try too much + self.throttled_response = lambda do |_env| + [ + 429, + { "Content-Type" => "application/json" }, + [ { error: "too many requests. chill out and try again later." }.to_json ] + ] + end +end diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 0000000..93364ba --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,13 @@ +Rswag::Api.configure do |c| + # Specify a root folder where Swagger JSON files are located + # This is used by the Swagger middleware to serve requests for API descriptions + # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure + # that it's configured to generate files in the same folder + c.openapi_root = Rails.root.to_s + "/swagger" + + # Inject a lambda function to alter the returned Swagger prior to serialization + # The function will have access to the rack env for the current request + # For example, you could leverage this to dynamically assign the "host" property + # + # c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 0000000..64c7e75 --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,15 @@ +Rswag::Ui.configure do |c| + # List the Swagger endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + # NOTE: If you're using rspec-api to expose Swagger files + # (under openapi_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + c.swagger_endpoint "/api-docs/v1/swagger.yaml", "API V1 Docs" + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' +end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 0000000..260e1c4 --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..03c166f --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,34 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# to prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..f9661e5 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,25 @@ +Rails.application.routes.draw do + mount Rswag::Ui::Engine => "/api-docs" + mount Rswag::Api::Engine => "/api-docs" + devise_for :users, skip: [ :registrations, :passwords, :confirmations ] + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + namespace :api do + namespace :v1 do + post "signup", to: "auth#signup" + post "login", to: "auth#login" + post "logout", to: "auth#logout" + post "refresh", to: "auth#refresh" + + get "protected", to: "protected#index" + get "profile", to: "profile#show" + + # admin routes + get "admin/dashboard", to: "admin#dashboard" + end + end +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/migrate/20250613174349_devise_create_users.rb b/db/migrate/20250613174349_devise_create_users.rb new file mode 100644 index 0000000..43f526a --- /dev/null +++ b/db/migrate/20250613174349_devise_create_users.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class DeviseCreateUsers < ActiveRecord::Migration[7.2] + def change + create_table :users do |t| + ## Database authenticatable + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + t.datetime :remember_created_at + + ## Trackable + # t.integer :sign_in_count, default: 0, null: false + # t.datetime :current_sign_in_at + # t.datetime :last_sign_in_at + # t.string :current_sign_in_ip + # t.string :last_sign_in_ip + + ## Confirmable + # t.string :confirmation_token + # t.datetime :confirmed_at + # t.datetime :confirmation_sent_at + # t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + # t.string :unlock_token # Only if unlock strategy is :email or :both + # t.datetime :locked_at + + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + # add_index :users, :confirmation_token, unique: true + # add_index :users, :unlock_token, unique: true + end +end diff --git a/db/migrate/20260128000001_create_blacklisted_tokens.rb b/db/migrate/20260128000001_create_blacklisted_tokens.rb new file mode 100644 index 0000000..13c0b2e --- /dev/null +++ b/db/migrate/20260128000001_create_blacklisted_tokens.rb @@ -0,0 +1,14 @@ +class CreateBlacklistedTokens < ActiveRecord::Migration[7.2] + def change + create_table :blacklisted_tokens do |t| + t.string :jti, null: false + t.references :user, null: false, foreign_key: true + t.datetime :exp, null: false + + t.timestamps + end + + add_index :blacklisted_tokens, :jti, unique: true + add_index :blacklisted_tokens, :exp + end +end diff --git a/db/migrate/20260128000002_create_refresh_tokens.rb b/db/migrate/20260128000002_create_refresh_tokens.rb new file mode 100644 index 0000000..ce38c16 --- /dev/null +++ b/db/migrate/20260128000002_create_refresh_tokens.rb @@ -0,0 +1,15 @@ +class CreateRefreshTokens < ActiveRecord::Migration[7.2] + def change + create_table :refresh_tokens do |t| + t.string :token, null: false + t.references :user, null: false, foreign_key: true + t.datetime :expires_at, null: false + t.boolean :revoked, default: false + + t.timestamps + end + + add_index :refresh_tokens, :token, unique: true + add_index :refresh_tokens, [:user_id, :revoked] + end +end diff --git a/db/migrate/20260128000003_add_role_to_users.rb b/db/migrate/20260128000003_add_role_to_users.rb new file mode 100644 index 0000000..017b0d9 --- /dev/null +++ b/db/migrate/20260128000003_add_role_to_users.rb @@ -0,0 +1,6 @@ +class AddRoleToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :role, :integer, default: 0, null: false + add_index :users, :role + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..66a9129 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,56 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 2026_01_28_000003) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "blacklisted_tokens", force: :cascade do |t| + t.string "jti", null: false + t.bigint "user_id", null: false + t.datetime "exp", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["exp"], name: "index_blacklisted_tokens_on_exp" + t.index ["jti"], name: "index_blacklisted_tokens_on_jti", unique: true + t.index ["user_id"], name: "index_blacklisted_tokens_on_user_id" + end + + create_table "refresh_tokens", force: :cascade do |t| + t.string "token", null: false + t.bigint "user_id", null: false + t.datetime "expires_at", null: false + t.boolean "revoked", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_refresh_tokens_on_token", unique: true + t.index ["user_id", "revoked"], name: "index_refresh_tokens_on_user_id_and_revoked" + t.index ["user_id"], name: "index_refresh_tokens_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "role", default: 0, null: false + 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 ["role"], name: "index_users_on_role" + end + + add_foreign_key "blacklisted_tokens", "users" + add_foreign_key "refresh_tokens", "users" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/json_web_token.rb b/lib/json_web_token.rb new file mode 100644 index 0000000..ff329ff --- /dev/null +++ b/lib/json_web_token.rb @@ -0,0 +1,32 @@ +require "jwt" + +# this class handles all the jwt token thing +# we use it to encode user data into a token, and decode it back +# it's simple, clean, and reusable, no need to overcomplicate it + +class JsonWebToken + # secret key used to sign the token + # make sure to keep this secret in .env (never hardcode and check before pushing to github) + # will crash if JWT_SECRET_KEY is not set - this is intentional for security + SECRET_KEY = ENV.fetch("JWT_SECRET_KEY") + + # encode payload into a jwt + # payload is usually like: { user_id: 1 } + # exp = token expiration (default: 1 hour from now for better security) + # jti = unique token identifier for blacklisting + def self.encode(payload, exp = 1.hour.from_now) + payload[:exp] = exp.to_i + payload[:jti] ||= SecureRandom.uuid + JWT.encode(payload, SECRET_KEY, "HS256") + end + + # decode jwt back into original payload + # returns the payload if token is valid, else it throws error + def self.decode(token) + decoded = JWT.decode(token, SECRET_KEY, true, algorithm: "HS256") + HashWithIndifferentAccess.new(decoded[0]) + rescue JWT::DecodeError => e + # if decoding fails, basic error raises so you can add yours as well + raise StandardError.new("Invalid token: #{e.message}") + end +end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/spec/models/blacklisted_token_spec.rb b/spec/models/blacklisted_token_spec.rb new file mode 100644 index 0000000..b516d0f --- /dev/null +++ b/spec/models/blacklisted_token_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe BlacklistedToken, type: :model do + let(:user) { User.create!(email: "test@example.com", password: "123456", password_confirmation: "123456") } + + describe "validations" do + it "requires jti" do + token = BlacklistedToken.new(user: user, exp: 1.hour.from_now) + expect(token.valid?).to be false + expect(token.errors[:jti]).to include("can't be blank") + end + + it "requires exp" do + token = BlacklistedToken.new(user: user, jti: SecureRandom.uuid) + expect(token.valid?).to be false + expect(token.errors[:exp]).to include("can't be blank") + end + + it "requires unique jti" do + jti = SecureRandom.uuid + BlacklistedToken.create!(user: user, jti: jti, exp: 1.hour.from_now) + + duplicate = BlacklistedToken.new(user: user, jti: jti, exp: 1.hour.from_now) + expect(duplicate.valid?).to be false + expect(duplicate.errors[:jti]).to include("has already been taken") + end + end + + describe ".blacklisted?" do + it "returns true for blacklisted tokens" do + jti = SecureRandom.uuid + BlacklistedToken.create!(user: user, jti: jti, exp: 1.hour.from_now) + + expect(BlacklistedToken.blacklisted?(jti)).to be true + end + + it "returns false for non-blacklisted tokens" do + expect(BlacklistedToken.blacklisted?("non-existent-jti")).to be false + end + end + + describe ".cleanup_expired" do + it "removes expired tokens" do + expired_token = BlacklistedToken.create!(user: user, jti: SecureRandom.uuid, exp: 1.day.ago) + valid_token = BlacklistedToken.create!(user: user, jti: SecureRandom.uuid, exp: 1.hour.from_now) + + expect { + BlacklistedToken.cleanup_expired + }.to change { BlacklistedToken.count }.by(-1) + + expect(BlacklistedToken.exists?(expired_token.id)).to be false + expect(BlacklistedToken.exists?(valid_token.id)).to be true + end + end +end diff --git a/spec/models/refresh_token_spec.rb b/spec/models/refresh_token_spec.rb new file mode 100644 index 0000000..e89f2e0 --- /dev/null +++ b/spec/models/refresh_token_spec.rb @@ -0,0 +1,71 @@ +require "rails_helper" + +RSpec.describe RefreshToken, type: :model do + let(:user) { User.create!(email: "test@example.com", password: "123456", password_confirmation: "123456") } + + describe "validations" do + it "generates token automatically" do + token = RefreshToken.create!(user: user) + expect(token.token).to be_present + end + + it "sets expiration automatically" do + token = RefreshToken.create!(user: user) + expect(token.expires_at).to be_present + expect(token.expires_at).to be > Time.current + end + + it "requires unique token" do + token1 = RefreshToken.create!(user: user) + token2 = RefreshToken.new(user: user, token: token1.token, expires_at: 7.days.from_now) + + expect(token2.valid?).to be false + expect(token2.errors[:token]).to include("has already been taken") + end + end + + describe "#active?" do + it "returns true for non-revoked, non-expired tokens" do + token = RefreshToken.create!(user: user) + expect(token.active?).to be true + end + + it "returns false for revoked tokens" do + token = RefreshToken.create!(user: user, revoked: true) + expect(token.active?).to be false + end + + it "returns false for expired tokens" do + token = RefreshToken.create!(user: user, expires_at: 1.day.ago) + expect(token.active?).to be false + end + end + + describe "#revoke!" do + it "marks token as revoked" do + token = RefreshToken.create!(user: user) + expect(token.revoked).to be false + + token.revoke! + expect(token.revoked).to be true + end + end + + describe ".cleanup_old_tokens" do + it "removes expired and revoked tokens" do + old_expired = RefreshToken.create!(user: user, expires_at: 31.days.ago) + old_revoked = RefreshToken.create!(user: user, revoked: true, created_at: 31.days.ago, expires_at: 1.day.from_now) + recent_revoked = RefreshToken.create!(user: user, revoked: true, expires_at: 1.day.from_now) + valid_token = RefreshToken.create!(user: user) + + expect { + RefreshToken.cleanup_old_tokens + }.to change { RefreshToken.count }.by(-2) + + expect(RefreshToken.exists?(old_expired.id)).to be false + expect(RefreshToken.exists?(old_revoked.id)).to be false + expect(RefreshToken.exists?(recent_revoked.id)).to be true + expect(RefreshToken.exists?(valid_token.id)).to be true + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..79811b8 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,72 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also this infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/requests/api/v1/admin_spec.rb b/spec/requests/api/v1/admin_spec.rb new file mode 100644 index 0000000..e13ebdb --- /dev/null +++ b/spec/requests/api/v1/admin_spec.rb @@ -0,0 +1,42 @@ +require "swagger_helper" + +RSpec.describe "api/v1/admin", type: :request do + path "/api/v1/admin/dashboard" do + get "admin dashboard (admin only)" do + tags "Admin" + produces "application/json" + security [ bearer_auth: [] ] + + response "200", "admin dashboard accessed" do + let!(:admin_user) { User.create!(email: "admin@example.com", password: "123456", password_confirmation: "123456", role: :admin) } + let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: admin_user.id)}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["message"]).to include("Welcome to admin dashboard") + expect(data["stats"]).to be_present + expect(data["stats"]["total_users"]).to be_a(Integer) + end + end + + response "403", "forbidden for non-admin users" do + let!(:regular_user) { User.create!(email: "user@example.com", password: "123456", password_confirmation: "123456", role: :user) } + let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: regular_user.id)}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to include("Forbidden") + end + end + + response "401", "unauthorized without token" do + let(:Authorization) { "" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to include("unauthorized") + end + end + end + end +end diff --git a/spec/requests/api/v1/auths_spec.rb b/spec/requests/api/v1/auths_spec.rb new file mode 100644 index 0000000..f3a85e1 --- /dev/null +++ b/spec/requests/api/v1/auths_spec.rb @@ -0,0 +1,37 @@ +require "swagger_helper" + +RSpec.describe "api/v1/auth", type: :request do + path "/api/v1/login" do + post "logs in a user" do + tags "Auth" + consumes "application/json" + produces "application/json" + security [] + parameter name: :credentials, in: :body, schema: { + type: :object, + required: %w[email password], + properties: { + email: { + type: :string, + example: "bob@random.com" + }, + password: { + type: :string, + example: "123456" + } + } + } + + response "200", "logged in" do + let!(:user) { User.create(email: "bob@random.com", password: "123456", password_confirmation: "123456") } + let(:credentials) { { email: "bob@random.com", password: "123456" } } + run_test! + end + + response "401", "invalid credentials" do + let(:credentials) { { email: "notbob@example.com", password: "wrong" } } + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/logout_spec.rb b/spec/requests/api/v1/logout_spec.rb new file mode 100644 index 0000000..aaa73db --- /dev/null +++ b/spec/requests/api/v1/logout_spec.rb @@ -0,0 +1,55 @@ +require "swagger_helper" + +RSpec.describe "api/v1/logout", type: :request do + path "/api/v1/logout" do + post "logs out user and blacklists token" do + tags "Auth" + consumes "application/json" + produces "application/json" + security [ bearer_auth: [] ] + + let!(:user) { User.create!(email: "test@example.com", password: "123456", password_confirmation: "123456") } + + response "200", "successfully logged out" do + let(:Authorization) do + token = JsonWebToken.encode(user_id: user.id) + "Bearer #{token}" + end + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["message"]).to include("Successfully logged out") + + # verify token was blacklisted by checking the response + # (we can't decode the token variable here as it's scoped to the let block) + end + end + + response "401", "unauthorized without token" do + let(:Authorization) { "" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to include("unauthorized") + end + end + end + end + + describe "blacklisted token rejection" do + it "rejects requests with blacklisted tokens" do + user = User.create!(email: "test@example.com", password: "123456", password_confirmation: "123456") + token = JsonWebToken.encode(user_id: user.id) + + # first logout to blacklist the token + post "/api/v1/logout", headers: { "Authorization" => "Bearer #{token}" } + expect(response).to have_http_status(:ok) + + # try to access protected endpoint with blacklisted token + get "/api/v1/profile", headers: { "Authorization" => "Bearer #{token}" } + expect(response).to have_http_status(:unauthorized) + data = JSON.parse(response.body) + expect(data["error"]).to include("Token has been revoked") + end + end +end diff --git a/spec/requests/api/v1/profile_spec.rb b/spec/requests/api/v1/profile_spec.rb new file mode 100644 index 0000000..10cee5c --- /dev/null +++ b/spec/requests/api/v1/profile_spec.rb @@ -0,0 +1,23 @@ +require "swagger_helper" + +RSpec.describe "api/v1/profile", type: :request do + path "/api/v1/profile" do + get "get current user info using jwt token" do + tags "Profile" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "profile fetched" do + let!(:user) { User.create(email: "bob@random.com", password: "123456", password_confirmation: "123456") } + let(:Authorization) { "Bearer #{JsonWebToken.encode(user_id: user.id)}" } + + run_test! + end + + response "401", "unauthorized access" do + let(:Authorization) { "" } + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/rack_attack_spec.rb b/spec/requests/api/v1/rack_attack_spec.rb new file mode 100644 index 0000000..9c8c152 --- /dev/null +++ b/spec/requests/api/v1/rack_attack_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe "Rack::Attack throttling", type: :request do + describe "POST /api/v1/login" do + let!(:user) do + User.create(email: "bob@random.com", password: "123456", password_confirmation: "123456") + end + + it "throttles after 3 login attempts" do + 3.times do + post "/api/v1/login", params: { + email: "bob@random.com", + password: "wrongpassword" + }.to_json, headers: { "CONTENT_TYPE" => "application/json" } + + expect(response.status).to_not eq(429) + end + + # this one is going to be blocked. too much attempt + post "/api/v1/login", params: { + email: "bob@random.com", + password: "wrongpassword" + }.to_json, headers: { "CONTENT_TYPE" => "application/json" } + + expect(response.status).to eq(429) + expect(response.body).to include("chill out") + end + end +end diff --git a/spec/requests/api/v1/refresh_spec.rb b/spec/requests/api/v1/refresh_spec.rb new file mode 100644 index 0000000..0a2afea --- /dev/null +++ b/spec/requests/api/v1/refresh_spec.rb @@ -0,0 +1,55 @@ +require "swagger_helper" + +RSpec.describe "api/v1/refresh", type: :request do + path "/api/v1/refresh" do + post "refreshes access token using refresh token" do + tags "Auth" + consumes "application/json" + produces "application/json" + security [] + + parameter name: :refresh_request, in: :body, schema: { + type: :object, + required: %w[refresh_token], + properties: { + refresh_token: { type: :string, example: "your_refresh_token_here" } + } + } + + let(:user) { User.create!(email: "test@example.com", password: "123456", password_confirmation: "123456") } + let(:refresh_token_record) { user.refresh_tokens.create! } + + response "200", "new access token issued" do + let(:refresh_request) { { refresh_token: refresh_token_record.token } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["access_token"]).to be_present + expect(data["user"]["email"]).to eq("test@example.com") + end + end + + response "401", "invalid or expired refresh token" do + let(:refresh_request) { { refresh_token: "invalid_token" } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to include("Invalid or expired refresh token") + end + end + + response "401", "revoked refresh token" do + before do + refresh_token_record.revoke! + end + + let(:refresh_request) { { refresh_token: refresh_token_record.token } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to include("Invalid or expired refresh token") + end + end + end + end +end diff --git a/spec/requests/api/v1/signups_spec.rb b/spec/requests/api/v1/signups_spec.rb new file mode 100644 index 0000000..2424ae9 --- /dev/null +++ b/spec/requests/api/v1/signups_spec.rb @@ -0,0 +1,51 @@ +require "swagger_helper" + +RSpec.describe "api/v1/signup", type: :request do + path "/api/v1/signup" do + post "registers a new user and returns a jwt" do + tags "Auth" + consumes "application/json" + produces "application/json" + security [] + + parameter name: :user, in: :body, schema: { + type: :object, + required: %w[email password password_confirmation], + properties: { + email: { type: :string, example: "newbob@example.com" }, + password: { type: :string, example: "123456" }, + password_confirmation: { type: :string, example: "123456" } + } + } + + response "201", "user created and token returned" do + let(:user) do + { + email: "newbob@example.com", + password: "123456", + password_confirmation: "123456" + } + end + run_test! + end + + response "422", "validation failed invalid email + password" do + let(:user) do + { + email: "", + password: "123456", + password_confirmation: "000000" + } + end + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["errors"]).to include( + "Email can't be blank", + "Password confirmation doesn't match Password" + ) + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..327b58e --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 0000000..f45366a --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + # NOTE: If you're using the rswag-api to serve API descriptions, you'll need + # to ensure that it's configured to serve Swagger from the same folder + config.openapi_root = Rails.root.join('swagger').to_s + + # Define one or more Swagger documents and provide global metadata for each one + # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will + # be generated at the provided relative path under openapi_root + # By default, the operations defined in spec files are added to the first + # document below. You can override this behavior by adding a openapi_spec tag to the + # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' + config.openapi_specs = { + 'v1/swagger.yaml' => { + openapi: '3.0.1', + info: { + title: 'API V1', + version: 'v1' + }, + paths: {}, + components: { + securitySchemes: { + bearer_auth: { + type: :http, + scheme: :bearer, + bearerFormat: :JWT + } + } + }, + security: [ { bearer_auth: [] } ], + servers: [ + { + url: '{defaultHost}', + variables: { + defaultHost: { + default: 'http://localhost:3000' + } + } + } + ] + } + } + + # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. + # The openapi_specs configuration option has the filename including format in + # the key, this may want to be changed to avoid putting yaml in json files. + # Defaults to json. Accepts ':json' and ':yaml'. + config.openapi_format = :yaml +end diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml new file mode 100644 index 0000000..25bf820 --- /dev/null +++ b/swagger/v1/swagger.yaml @@ -0,0 +1,138 @@ +--- +openapi: 3.0.1 +info: + title: API V1 + version: v1 +paths: + "/api/v1/admin/dashboard": + get: + summary: admin dashboard (admin only) + tags: + - Admin + security: + - bearer_auth: [] + responses: + '200': + description: admin dashboard accessed + '403': + description: forbidden for non-admin users + '401': + description: unauthorized without token + "/api/v1/login": + post: + summary: logs in a user + tags: + - Auth + security: [] + parameters: [] + responses: + '200': + description: logged in + '401': + description: invalid credentials + requestBody: + content: + application/json: + schema: + type: object + required: + - email + - password + properties: + email: + type: string + example: bob@random.com + password: + type: string + example: '123456' + "/api/v1/logout": + post: + summary: logs out user and blacklists token + tags: + - Auth + security: + - bearer_auth: [] + responses: + '200': + description: successfully logged out + '401': + description: unauthorized without token + "/api/v1/profile": + get: + summary: get current user info using jwt token + tags: + - Profile + security: + - bearer_auth: [] + responses: + '200': + description: profile fetched + '401': + description: unauthorized access + "/api/v1/refresh": + post: + summary: refreshes access token using refresh token + tags: + - Auth + security: [] + parameters: [] + responses: + '200': + description: new access token issued + '401': + description: revoked refresh token + requestBody: + content: + application/json: + schema: + type: object + required: + - refresh_token + properties: + refresh_token: + type: string + example: your_refresh_token_here + "/api/v1/signup": + post: + summary: registers a new user and returns a jwt + tags: + - Auth + security: [] + parameters: [] + responses: + '201': + description: user created and token returned + '422': + description: validation failed invalid email + password + requestBody: + content: + application/json: + schema: + type: object + required: + - email + - password + - password_confirmation + properties: + email: + type: string + example: newbob@example.com + password: + type: string + example: '123456' + password_confirmation: + type: string + example: '123456' +components: + securitySchemes: + bearer_auth: + type: http + scheme: bearer + bearerFormat: JWT +security: +- bearer_auth: [] +servers: +- url: "{defaultHost}" + variables: + defaultHost: + default: http://localhost:3000 diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29