diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..893e0c2 --- /dev/null +++ b/.envrc @@ -0,0 +1,40 @@ +# Run any command in this library's bin/ without the bin/ prefix! +PATH_add bin + +# Only add things to this file that should be shared with the team. + +# **dotenv** (See end of file for .env.local integration) +# .env would override anything in this file, if enabled. +# .env is a DOCKER standard, and if we use it, it would be in deployed, or DOCKER, environments. +# Override and customize anything below in your own .env.local +# If you are using dotenv and not direnv, +# copy the following `export` statements to your own .env file. + +### General Ruby ### +# Turn off Ruby Warnings about deprecated code +# export RUBYOPT="-W0" + +### External Testing Controls +export K_SOUP_COV_DO=true # Means you want code coverage +# Available formats are html, xml, rcov, lcov, json, tty +export K_SOUP_COV_COMMAND_NAME="Minitest Coverage" +export K_SOUP_COV_FORMATTERS="html,tty" +export K_SOUP_COV_MIN_BRANCH=69 # Means you want to enforce X% branch coverage +export K_SOUP_COV_MIN_LINE=88 # Means you want to enforce X% line coverage +export K_SOUP_COV_MIN_HARD=true # Means you want the build to fail if the coverage thresholds are not met +export K_SOUP_COV_MULTI_FORMATTERS=true +export MAX_ROWS=1 # Setting for simplecov-console gem for tty output, limits to the worst N rows of bad coverage + +# External Debugging Controls +export REQUIRE_BENCH=false + +# Internal Debugging Controls +export DEBUG=false # do not allow byebug statements (override in .env.local) + +# .env would override anything in this file, if `dotenv` is uncommented below. +# .env is a DOCKER standard, and if we use it, it would be in deployed, or DOCKER, environments, +# and that is why we generally want to leave it commented out. +# dotenv + +# .env.local will override anything in this file. +dotenv_if_exists .env.local diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2ef9219 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +buy_me_a_coffee: pboling +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +github: [pboling] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +issuehunt: pboling # Replace with a single IssueHunt username +ko_fi: pboling # Replace with a single Ko-fi username +liberapay: pboling # Replace with a single Liberapay username +open_collective: # Replace with a single Open Collective username +patreon: galtzo # Replace with a single Patreon username +polar: pboling +thanks_dev: u/gh/pboling +tidelift: rubygems/masq2 # Replace with a single Tidelift platform-name/package-name e.g., npm/babel diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..46f1c90 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "rubocop-lts" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..502c2a0 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ main, "*-stable" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main, "*-stable" ] + schedule: + - cron: '35 1 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'ruby' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..a228df7 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,121 @@ +name: Test Coverage + +env: + K_SOUP_COV_MIN_BRANCH: 2 + K_SOUP_COV_MIN_LINE: 46 + K_SOUP_COV_MIN_HARD: true + K_SOUP_COV_DO: true + K_SOUP_COV_COMMAND_NAME: "RSpec Coverage" + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + coverage: + name: Code Coverage on ${{ matrix.ruby }}@current + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Coverage + - ruby: "ruby" + appraisal: "coverage" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - uses: amancevice/setup-code-climate@v2 + name: CodeClimate Install + if: ${{ github.event_name != 'pull_request' }} + with: + cc_test_reporter_id: ${{ secrets.CC_TEST_REPORTER_ID }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ matrix.ruby }}" + rubygems: "${{ matrix.rubygems }}" + bundler: "${{ matrix.bundler }}" + bundler-cache: false + + - name: CodeClimate Pre-build Notification + run: cc-test-reporter before-build + if: ${{ github.event_name != 'pull_request' }} + continue-on-error: ${{ matrix.experimental != 'false' }} + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }}@current via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} + + - name: CodeClimate Post-build Notification + run: cc-test-reporter after-build + if: ${{ github.event_name != 'pull_request' }} + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Code Coverage Summary Report + uses: irongut/CodeCoverageSummary@v1.3.0 + if: ${{ github.event_name == 'pull_request' }} + with: + filename: ./coverage/coverage.xml + badge: true + fail_below_min: true + format: markdown + hide_branch_rate: false + hide_complexity: true + indicators: true + output: both + thresholds: '69 80' + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: ${{ github.event_name == 'pull_request' }} + with: + recreate: true + path: code-coverage-results.md + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: ${{ matrix.experimental != 'false' }} + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml new file mode 100644 index 0000000..e24d00f --- /dev/null +++ b/.github/workflows/current.yml @@ -0,0 +1,75 @@ +# Targets the evergreen latest release of ruby, truffleruby, and jruby +name: Current + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }} + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.4, Rails 7.2 + - ruby: "ruby" + appraisal: "rails-7-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.4, Rails 8.0 + - ruby: "ruby" + appraisal: "rails-8-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }}@${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..0d4a013 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/heads.yml b/.github/workflows/heads.yml new file mode 100644 index 0000000..708d394 --- /dev/null +++ b/.github/workflows/heads.yml @@ -0,0 +1,68 @@ +name: Heads + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + name: Specs ${{ matrix.ruby }}@${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: true + matrix: + include: + # NOTE: Heads use default rubygems / bundler; their defaults are custom, unreleased, and from the future! + # ruby-head + - ruby: "ruby-head" + appraisal: "rails-8-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: default + bundler: default + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }}@${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.github/workflows/legacy.yml b/.github/workflows/legacy.yml new file mode 100644 index 0000000..3a524d2 --- /dev/null +++ b/.github/workflows/legacy.yml @@ -0,0 +1,75 @@ +name: MRI 3.0 (EOL) + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + name: Specs ${{ matrix.ruby }} ${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Ruby 3.0, Rails 6.1 + - ruby: "ruby-3.0" + appraisal: "rails-6-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.5.23' + bundler: '2.5.23' + + # Ruby 3.0, Rails 7.0 + - ruby: "ruby-3.0" + appraisal: "rails-7-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.5.23' + bundler: '2.5.23' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..17ff0bd --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,64 @@ +name: Style + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + rubocop: + name: Style on ${{ matrix.ruby }}@current + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Style + - ruby: "ruby" + appraisal: "style" + exec_cmd: "rake rubocop_gradual:check" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Run ${{ matrix.appraisal }} checks via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.github/workflows/supported.yml b/.github/workflows/supported.yml new file mode 100644 index 0000000..aa237eb --- /dev/null +++ b/.github/workflows/supported.yml @@ -0,0 +1,114 @@ +name: MRI Non-EOL + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + name: Specs ${{ matrix.ruby }} ${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + matrix: + include: + # Ruby 3.1, Rails 7.0 + - ruby: "ruby-3.1" + appraisal: "rails-7-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.1, Rails 7.1 + - ruby: "ruby-3.1" + appraisal: "rails-7-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.2, Rails 7.1 + - ruby: "ruby-3.2" + appraisal: "rails-7-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.2, Rails 7.2 + - ruby: "ruby-3.2" + appraisal: "rails-7-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.2, Rails 8.0 + - ruby: "ruby-3.2" + appraisal: "rails-8-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.3, Rails 7.2 + - ruby: "ruby-3.3" + appraisal: "rails-7-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + # Ruby 3.3, Rails 8.0 + - ruby: "ruby-3.3" + appraisal: "rails-8-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: latest + bundler: latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.ruby }} ${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }} ${{ matrix.appraisal }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.github/workflows/unsupported.yml b/.github/workflows/unsupported.yml new file mode 100644 index 0000000..6171643 --- /dev/null +++ b/.github/workflows/unsupported.yml @@ -0,0 +1,83 @@ +name: MRI 2.7 (EOL) + +env: + K_SOUP_COV_DO: false + +on: + push: + branches: + - 'main' + tags: + - '!*' # Do not execute on tags + pull_request: + branches: + - '*' + # Allow manually triggering the workflow. + workflow_dispatch: + +permissions: + contents: read + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + test: + name: Specs ${{ matrix.ruby }} ${{ matrix.appraisal }}${{ matrix.name_extra || '' }} + if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')" + runs-on: ubuntu-22.04 + continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }} + env: # $BUNDLE_GEMFILE must be set at job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile + strategy: + fail-fast: false + matrix: + include: + # Ruby 2.7, Rails 5.2 + - ruby: "ruby-2.7" + appraisal: "rails-5-2" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.4.22' + bundler: '2.4.22' + + # Ruby 2.7, Rails 6.0 + - ruby: "ruby-2.7" + appraisal: "rails-6-0" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.4.22' + bundler: '2.4.22' + + # Ruby 2.7, Rails 6.1 + - ruby: "ruby-2.7" + appraisal: "rails-6-1" + exec_cmd: "rake test" + gemfile: "Appraisal.root" + rubygems: '3.4.22' + bundler: '2.4.22' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby & RubyGems + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: ${{ matrix.rubygems }} + bundler: ${{ matrix.bundler }} + bundler-cache: false + + # Raw `bundle` will use the BUNDLE_GEMFILE set to matrix.gemfile (i.e. Appraisal.root) + # We need to do this first to get appraisal installed. + # NOTE: This does not use the main Gemfile at all. + - name: Install Root Appraisal + run: bundle + - name: Appraisal for ${{ matrix.appraisal }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle + - name: Tests for ${{ matrix.ruby }} via ${{ matrix.exec_cmd }} + run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }} diff --git a/.gitignore b/.gitignore index f435572..b4784ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,35 @@ -.bundle -.DS_Store -.loadpath -.project -.ruby-version -.rvmrc -Capfile -Thumbs.db -db/*.sqlite3 -doc/* -log/*.log -pkg/ -public/cache/**/* -run_tests.sh -test/dummy/db/*.sqlite3 -test/dummy/log/*.log -test/dummy/tmp -test/dummy/.sass-cache -tmp -vendor/ruby +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/tmp/ -# Ignore uploaded files in development, if ever there are any from ActiveStorage -/storage/* +# Debugging +.byebug_history -/public/assets +# rspec failure tracking +.rspec_status + +# IDEs +/.idea/ + +# Packaging Artifacts +*.gem +gemfiles/*.gemfile.lock +Appraisal.*.gemfile.lock + +# Engine stuff +/db/*.sqlite +/db/*.sqlite3 +/log/*.log +/public/cache/**/* +/vendor/ + +# Dummy Rails App +/test/internal/db/*.sqlite3 +/test/internal/db/*.sqlite +/test/internal/log/*.log +/test/internal/tmp +/test/internal/.sass-cache diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..b95daa8 --- /dev/null +++ b/.rspec @@ -0,0 +1,4 @@ +--format documentation +--color +--require spec_helper +--warnings diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..8273e09 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,2 @@ +inherit_gem: + rubocop-lts: config/rubygem.yml diff --git a/.rubocop_gradual.lock b/.rubocop_gradual.lock new file mode 100644 index 0000000..2c8fd29 --- /dev/null +++ b/.rubocop_gradual.lock @@ -0,0 +1,32 @@ +{ + "app/controllers/masq/info_controller.rb:3069306562": [ + [13, 65, 2, "Style/AndOr: Use `||` instead of `or`.", 5861240] + ], + "app/controllers/masq/passwords_controller.rb:4013562638": [ + [8, 18, 1, "Lint/AssignmentInCondition: Wrap assignment in parentheses if intentional", 177560] + ], + "app/controllers/masq/sessions_controller.rb:926273284": [ + [39, 21, 3, "Style/AndOr: Use `&&` instead of `and`.", 193409806] + ], + "lib/masq/authenticated_system.rb:2956957840": [ + [95, 5, 3, "Lint/IneffectiveAccessModifier: `protected` (on line 3) does not make singleton methods protected. Use `protected` inside a `class << self` block instead.", 193404514], + [95, 5, 111, "Style/ClassMethodsDefinitions: Use `class << self` to define a class method.", 4258707989], + [127, 10, 34, "Style/SafeNavigation: Use safe navigation (`&.`) instead of checking if an object exists before calling the method.", 631550519] + ], + "lib/masq/openid_server_system.rb:2879462441": [ + [106, 26, 41, "Style/SafeNavigation: Use safe navigation (`&.`) instead of checking if an object exists before calling the method.", 3858462676] + ], + "lib/tasks/masq_tasks.rake:1483017689": [ + [39, 21, 42, "Style/SafeNavigation: Use safe navigation (`&.`) instead of checking if an object exists before calling the method.", 4054527944] + ], + "masq.gemspec:1107948888": [ + [72, 3, 26, "Gemspec/DependencyVersion: Dependency version specification is required.", 1260806891], + [73, 3, 32, "Gemspec/DependencyVersion: Dependency version specification is required.", 855136127], + [76, 3, 33, "Gemspec/DependencyVersion: Dependency version specification is required.", 3159729161], + [78, 3, 30, "Gemspec/DependencyVersion: Dependency version specification is required.", 2010339150], + [89, 3, 72, "Gemspec/DependencyVersion: Dependency version specification is required.", 2491072344] + ], + "test/test_helper.rb:2496707493": [ + [206, 45, 1, "Lint/AssignmentInCondition: Wrap assignment in parentheses if intentional", 177560] + ] +} diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..10b07d9 --- /dev/null +++ b/.simplecov @@ -0,0 +1,6 @@ +require "kettle/soup/cover/config" # This lives in the kettle-soup-cover gem! +require "minitest/simplecov_plugin" # This lives in the simplecov gem! + +Minitest.plugin_simplecov_init({}) + +SimpleCov.start diff --git a/.tool-versions b/.tool-versions index 59511e1..ae5ecdb 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 2.7.8 +ruby 3.4.2 diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..f9cf06c --- /dev/null +++ b/.yardopts @@ -0,0 +1 @@ +--plugin junk diff --git a/Appraisal.root.gemfile b/Appraisal.root.gemfile new file mode 100644 index 0000000..638ec20 --- /dev/null +++ b/Appraisal.root.gemfile @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +source "https://rubygems.org" + +# Appraisal Root Gemfile is for running appraisal to generate the Appraisal Gemfiles +# in gemfiles/*gemfile. +# On CI, we use it for the Appraisal-based builds. +# We do not load the standard Gemfile, as it is tailored for local development. + +# NOTE: Due to variations in relative pathing, we can't use the gemfiles/modular gemfiles here, +# since they would be added to the Appraisals gemfiles with the wrong relative path. + +# Gems that have been removed from stdlib need to be added to the Gemfile explicitly +gem "logger" +gem "rexml" +gem "mutex_m", "~> 0.2" +gem "stringio", "~> 3.0" + +gemspec + +gem "appraisal", github: "pboling/appraisal", branch: "galtzo" diff --git a/Appraisals b/Appraisals new file mode 100644 index 0000000..1124f50 --- /dev/null +++ b/Appraisals @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Compat: Ruby >= 2.2.2 +# Test Matrix: +# - Ruby 2.7 +appraise "rails-5-2" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 1.4" + + gem "rails", "~> 5.2.8.1" + gem "nokogiri" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 2.5 +# Test Matrix: +# - Ruby 2.7 +appraise "rails-6-0" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 1.4" + + gem "rails", "~> 6.0.6.1" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 2.5 +# Test Matrix: +# - Ruby 2.7 +# - Ruby 3.0 +appraise "rails-6-1" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 1.4" + + gem "rails", "~> 6.1.7.10" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 2.7 +# Test Matrix: +# - Ruby 3.0 +# - Ruby 3.1 +appraise "rails-7-0" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 1.4" + + gem "rails", "~> 7.0.8.7" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 2.7 +# Test Matrix: +# - jruby-9.4 (targets Ruby 3.1 compatibility) +# - truffleruby-22.3 (targets Ruby 3.0 compatibility) +# - Ruby 3.1 +# - Ruby 3.2 +appraise "rails-7-1" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 7.1.5.1" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 3.1 +# Test Matrix: +# - jruby-9.4 (targets Ruby 3.1 compatibility) +# - truffleruby-23.0 (targets Ruby 3.1 compatibility) +# - Ruby 3.2 +# - Ruby 3.3 +# - Ruby 3.4 +appraise "rails-7-2" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 7.2.2.1" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Compat: Ruby >= 3.2 +# Test Matrix: +# - truffleruby-23.1 (targets Ruby 3.2 compatibility) +# - truffleruby-24.1 (targets Ruby 3.3 compatibility) +# - Ruby 3.2 +# - Ruby 3.3 +# - Ruby 3.4 +appraise "rails-8-0" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 8.0.2" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Only run security audit on latest Ruby version +appraise "audit" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 8.0.2" + eval_gemfile "modular/audit.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Only run coverage on latest Ruby version +appraise "coverage" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 8.0.2" + eval_gemfile "modular/coverage.gemfile" + eval_gemfile "modular/mini_testing.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end + +# Only run linter on latest Ruby version (but, in support of oldest supported Ruby version) +appraise "style" do + # Load order is very important with combustion! + gem "combustion", "~> 1.5" + gem "sqlite3", "~> 2.0" + + gem "rails", "~> 8.0.2" + eval_gemfile "modular/style.gemfile" + remove_gem "appraisal" # only present because it must be in the gemfile because we target a git branch +end diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d19c9..142abb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,37 @@ -# CHANGELOG +# Changelog + +[![SemVer 2.0.0][📌semver-img]][📌semver] [![Keep-A-Changelog 1.0.0][📗keep-changelog-img]][📗keep-changelog] + +All notable changes to this project will be documented in this file. + +Since version 1.0, the format is based on [Keep a Changelog][📗keep-changelog], +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and [yes][📌major-versions-not-sacred], platform and engine support are part of the [public API][📌semver-breaking]. +Please file a bug if you notice a violation of semantic versioning. + +[📌semver]: https://semver.org/spec/v2.0.0.html +[📌semver-img]: https://img.shields.io/badge/semver-2.0.0-FFDD67.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-FFDD67.svg?style=flat + +## [Unreleased] +### Added +### Changed +### Fixed +### Removed + +## [1.0.0] - 2025-02-25 ([tag][1.0.0t]) +- COVERAGE: 98.44% -- 63/64 lines in 6 files +- BRANCH COVERAGE: 94.44% -- 17/18 branches in 6 files +- 63.64% documented +### Added +- Compatibility with Rails v5.2, v6.x, 7.x, 8.0 +- Compatibility with Ruby 2.7.8, 3.x +### Removed +- Dropped compatibility with Rails < 5.2 +- Dropped compatibility with Ruby < 2.7.8 ## v0.3.4 @@ -42,3 +75,7 @@ ## v0.2.5 * [Security] Updated Rails to version 3.2.11 + +[Unreleased]: https://github.com/oauth-xx/masq2/compare/v0.1.7...HEAD +[0.1.7]: https://github.com/oauth-xx/masq2/compare/v0.1.16...v0.1.7 +[0.1.7t]: https://github.com/oauth-xx/masq2/tags/v0.1.6 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6be4700 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,135 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[![Contact BDFL][🚂bdfl-contact-img]][🚂bdfl-contact]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations +[🚂bdfl-contact]: http://www.railsbling.com/contact +[🚂bdfl-contact-img]: https://img.shields.io/badge/Contact-BDFL-0093D0.svg?style=flat&logo=rubyonrails&logoColor=red diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3ec9dfc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,151 @@ +# Contributing + +Bug reports and pull requests are welcome on GitLab at [https://gitlab.com/oauth-xx/masq2][🚎src-main] +. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to +the [code of conduct][🤝conduct]. + +To submit a patch, please fork the project and create a patch with tests. +Once you're happy with it send a pull request. + +We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it. + +## You can help! + +Simply follow these instructions: + +1. Fork the repository +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Make some fixes. +4. Commit your changes (`git commit -am 'Added some feature'`) +5. Push to the branch (`git push origin my-new-feature`) +6. Make sure to add tests for it. This is important, so it doesn't break in a future release. +7. Create new Pull Request. + +## Appraisals + +From time to time the appraisal gemfiles in `gemfiles/` will need to be updated. +They are created and updated with the commands: + +NOTE: We run on a [fork][🚎appraisal-fork] of Appraisal. + +Please upvote the PR for `eval_gemfile` [support][🚎appraisal-eval-gemfile-pr] + +```shell +BUNDLE_GEMFILE=Appraisal.root.gemfile bundle +BUNDLE_GEMFILE=Appraisal.root.gemfile bundle exec appraisal update +``` + +When adding an appraisal to CI check the [runner tool cache][🏃‍♂️runner-tool-cache] to see which runner to use. + +When fixing an issue in CI with a specific appraisal: +```shell +adsf local ruby 3.0.7 # or whatever version you need +BUNDLE_GEMFILE=Appraisal.root.gemfile bundle install +BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle # or whatever appraisal you need +BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle exec rake test # or whatever command failed +``` + +## The Reek List + +Take a look at the `reek` list which is the file called `REEK` and find something to improve. + +To refresh the `reek` list: + +```bash +bundle exec reek > REEK +``` + +## Run Tests + +To run all tests + +```bash +bundle exec rake test +``` + +To run a single test: + +```bash +bundle exec ruby -I test file/path/to/test.rb --name test_name_that_is_real +``` + +Just swap out the example file path and test name, to be something like below: + +```bash +bundle exec ruby -I test test/functional/masq/sessions_controller_test.rb --name test_should_authenticate_with_password_and_yubico_otp +``` + +## Lint It + +Run all the default tasks, which includes running the gradually autocorrecting linter, `rubocop-gradual`. + +```bash +bundle exec rake +``` + +Or just run the linter. + +```bash +bundle exec rake rubocop_gradual:autocorrect +``` + +## Contributors + +Your picture could be here! + +[![Contributors][🖐contributors-img]][🖐contributors] + +Made with [contributors-img][🖐contrib-rocks]. + +Also see GitLab Contributors: [https://gitlab.com/oauth-xx/masq2/-/graphs/main][🚎contributors-gl] + +## For Maintainers + +### One-time, Per-maintainer, Setup + +**IMPORTANT**: Your public key for signing gems will need to be picked up by the line in the +`gemspec` defining the `spec.cert_chain` (check the relevant ENV variables there), +in order to sign the new release. +See: [RubyGems Security Guide][🔒️rubygems-security-guide] + +### To release a new version: + +1. Run `bin/setup && bin/rake` as a tests, coverage, & linting sanity check +2. Update the version number in `version.rb`, and ensure `CHANGELOG.md` reflects changes +3. Run `bin/setup && bin/rake` again as a secondary check, and to update `Gemfile.lock` +4. Run `git commit -am "🔖 Prepare release v"` to commit the changes +5. Run `git push` to trigger the final CI pipeline before release, & merge PRs + - NOTE: Remember to [check the build][🧪build]! +6. Run `export GIT_TRUNK_BRANCH_NAME="$(git remote show origin | grep 'HEAD branch' | cut -d ' ' -f5)" && echo $GIT_TRUNK_BRANCH_NAME` +7. Run `git checkout $GIT_TRUNK_BRANCH_NAME` +8. Run `git pull origin $GIT_TRUNK_BRANCH_NAME` to ensure you will release the latest trunk code +9. Set `SOURCE_DATE_EPOCH` so `rake build` and `rake release` use same timestamp, and generate same checksums + - Run `export SOURCE_DATE_EPOCH=$EPOCHSECONDS && echo $SOURCE_DATE_EPOCH` + - If the echo above has no output, then it didn't work. + - Note that you'll need the `zsh/datetime` module, if running `zsh`. + - In `bash` you can use `date +%s` instead, i.e. `export SOURCE_DATE_EPOCH=$(date +%s) && echo $SOURCE_DATE_EPOCH` +10. Run `bundle exec rake build` +11. Run `bin/gem_checksums` (more context [1][🔒️rubygems-checksums-pr] and [2][🔒️rubygems-guides-pr]) + to create SHA-256 and SHA-512 checksums + - Checksums will be committed automatically by the script, but not pushed +12. Run `bundle exec rake release` which will create a git tag for the version, + push git commits and tags, and push the `.gem` file to [rubygems.org][💎rubygems] + +[🚎src-main]: https://gitlab.com/oauth-xx/masq2 +[🧪build]: https://github.com/oauth-xx/masq2/actions +[🤝conduct]: https://gitlab.com/oauth-xx/masq2/-/blob/main/CODE_OF_CONDUCT.md +[🖐contrib-rocks]: https://contrib.rocks +[🖐contributors]: https://github.com/oauth-xx/masq2/graphs/contributors +[🚎contributors-gl]: https://gitlab.com/oauth-xx/masq2/-/graphs/main +[🖐contributors-img]: https://contrib.rocks/image?repo=oauth-xx/masq2 +[💎rubygems]: https://rubygems.org +[🔒️rubygems-security-guide]: https://guides.rubygems.org/security/#building-gems +[🔒️rubygems-checksums-pr]: https://github.com/rubygems/rubygems/pull/6022 +[🔒️rubygems-guides-pr]: https://github.com/rubygems/guides/pull/325 +[📗keep-changelog]: https://keepachangelog.com/en/1.0.0/ +[📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-FFDD67.svg?style=flat +[📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139 +[📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html +[🚎appraisal-eval-gemfile-pr]: https://github.com/thoughtbot/appraisal/pull/248 +[🚎appraisal-fork]: https://github.com/pboling/appraisal/tree/galtzo +[🏃‍♂️runner-tool-cache]: https://github.com/ruby/ruby-builder/releases/tag/toolcache diff --git a/Gemfile b/Gemfile index 49f7560..71cb9e8 100644 --- a/Gemfile +++ b/Gemfile @@ -1,22 +1,50 @@ source "https://rubygems.org" -group :development, :test do - platforms :ruby, :mswin, :mingw do - gem 'sqlite3' - gem 'mysql2' - gem 'pg' - gem 'byebug' - #gem 'activerecord-oracle_enhanced-adapter' - #gem 'ruby-plsql' - #gem 'ruby-oci8' - end - gem 'minitest' - gem 'rails-controller-testing' - gem 'test-unit', '~> 3.0' - gem 'mocha' - gem 'ruby_gntp' - gem 'guard-minitest' - gem 'rb-fsevent', :require => false -end +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } +git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } +#### IMPORTANT ####################################################### +# Gemfile is for local development ONLY; Gemfile is NOT loaded in CI # +####################################################### IMPORTANT #### + +# Load order it important for combustion, so make sure it loads first! +gem "combustion", "~> 1.5" + +# Gems that have been removed from stdlib need to be added to the Gemfile explicitly +gem "logger" +gem "rexml" +gem "mutex_m", "~> 0.2" +gem "stringio", "~> 3.0" + +# Include dependencies from .gemspec gemspec + +# Security Audit +eval_gemfile "gemfiles/modular/audit.gemfile" + +# Debugging +eval_gemfile "gemfiles/modular/debug.gemfile" + +# Code Coverage +eval_gemfile "gemfiles/modular/coverage.gemfile" + +# Linting +eval_gemfile "gemfiles/modular/style.gemfile" + +# Documentation +eval_gemfile "gemfiles/modular/documentation.gemfile" + +# DB Adapters +eval_gemfile "gemfiles/modular/db_adapters.gemfile" + +# Testing +eval_gemfile "gemfiles/modular/mini_testing.gemfile" + +# Local Testing (not applicable to CI) +# gem "guard-minitest" + +gem "appraisal", github: "pboling/appraisal", branch: "galtzo" + +# For local testing we'll use Rails v8.0 +# In CI we'll use `combustion` and `appraisal` to test other versions +gem "rails", "~> 8.0", ">= 8.0.2" diff --git a/Gemfile.lock b/Gemfile.lock index 4f42034..67ce9fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,24 @@ +GIT + remote: https://github.com/VitalConnectInc/yard-junk + revision: 709571e184f14d72e67fbfbe4fb65a5b62e1786e + branch: next + specs: + yard-junk (0.0.10) + backports (>= 3.18) + ostruct + rainbow + yard + +GIT + remote: https://github.com/pboling/appraisal + revision: a3a3e4b7db67d9b085f96b2ffddd2b51bd8a1196 + branch: galtzo + specs: + appraisal (3.0.0.rc1) + bundler (>= 1.17.3) + rake (>= 10) + thor (>= 0.14) + PATH remote: . specs: @@ -5,7 +26,6 @@ PATH erb i18n_data rails (>= 5.2.8.1) - rails-controller-testing ruby-openid2 (~> 3.1) ruby-yadis version_gem (~> 1.1, >= 1.1.6) @@ -14,68 +34,160 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (5.2.8.1) - actionpack (= 5.2.8.1) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.8.1) - actionpack (= 5.2.8.1) - actionview (= 5.2.8.1) - activejob (= 5.2.8.1) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (5.2.8.1) - actionview (= 5.2.8.1) - activesupport (= 5.2.8.1) - rack (~> 2.0, >= 2.0.8) + zeitwerk (~> 2.6) + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.8.1) - activesupport (= 5.2.8.1) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.2) + activesupport (= 8.0.2) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (5.2.8.1) - activesupport (= 5.2.8.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.2) + activesupport (= 8.0.2) globalid (>= 0.3.6) - activemodel (5.2.8.1) - activesupport (= 5.2.8.1) - activerecord (5.2.8.1) - activemodel (= 5.2.8.1) - activesupport (= 5.2.8.1) - arel (>= 9.0) - activestorage (5.2.8.1) - actionpack (= 5.2.8.1) - activerecord (= 5.2.8.1) - marcel (~> 1.0.0) - activesupport (5.2.8.1) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - arel (9.0.0) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) + timeout (>= 0.4.0) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) + marcel (~> 1.0) + activesupport (8.0.2) + 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) + uri (>= 0.13.1) + ansi (1.5.0) + ast (2.4.3) + backports (3.25.1) base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) builder (3.3.0) - byebug (11.1.3) - cgi (0.4.1) - concurrent-ruby (1.3.4) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + cgi (0.4.2) + coderay (1.1.3) + combustion (1.5.0) + activesupport (>= 3.0.0) + railties (>= 3.0.0) + thor (>= 0.14.6) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) crass (1.0.6) date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.6.1) + diffy (3.4.3) + docile (1.4.1) + drb (2.2.1) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.2.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.14.1) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.5) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.8.2) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) erb (4.0.4) cgi (>= 0.3.3) erubi (1.13.1) - globalid (1.1.0) - activesupport (>= 5.0) - guard-compat (1.2.1) - guard-minitest (2.4.6) - guard-compat (~> 1.2) - minitest (>= 3.0) - i18n (1.14.6) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n_data (0.17.1) + i18n_data (1.1.0) simple_po_parser (~> 1.1) + io-console (0.8.0) + irb (1.15.1) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.10.2) + kettle-soup-cover (1.0.4) + simplecov (~> 0.22) + simplecov-cobertura (~> 2.1) + simplecov-console (~> 0.9, >= 0.9.1) + simplecov-html (~> 0.12) + simplecov-lcov (~> 0.8) + simplecov-rcov (~> 0.3, >= 0.3.3) + simplecov_json_formatter (~> 0.1, >= 0.1.4) + version_gem (~> 1.1, >= 1.1.4) + language_server-protocol (3.17.0.4) + lint_roller (1.1.0) + logger (1.6.6) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -87,42 +199,65 @@ GEM marcel (1.0.4) method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.8) - minitest (5.25.4) + minitest (5.25.5) + minitest-retry (0.2.5) + minitest (>= 5.0) mocha (2.7.1) ruby2_keywords (>= 0.0.5) + mutex_m (0.3.0) mysql2 (0.5.6) - net-imap (0.4.18) + net-imap (0.5.6) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.15.7-arm64-darwin) + nokogiri (1.18.6-arm64-darwin) racc (~> 1.4) + ostruct (0.6.1) + parallel (1.26.3) + parser (3.3.7.2) + ast (~> 2.4.1) + racc pg (1.5.9) power_assert (2.0.5) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.4.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + psych (5.2.3) + date + stringio racc (1.8.1) - rack (2.2.10) + rack (3.1.12) + rack-session (2.1.0) + base64 (>= 0.1.0) + rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rails (5.2.8.1) - actioncable (= 5.2.8.1) - actionmailer (= 5.2.8.1) - actionpack (= 5.2.8.1) - actionview (= 5.2.8.1) - activejob (= 5.2.8.1) - activemodel (= 5.2.8.1) - activerecord (= 5.2.8.1) - activestorage (= 5.2.8.1) - activesupport (= 5.2.8.1) - bundler (>= 1.3.0) - railties (= 5.2.8.1) - sprockets-rails (>= 2.0.0) + rackup (2.2.1) + rack (>= 3) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) + bundler (>= 1.15.0) + railties (= 8.0.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -134,59 +269,180 @@ GEM 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 (5.2.8.1) - actionpack (= 5.2.8.1) - activesupport (= 5.2.8.1) - method_source - rake (>= 0.8.7) - thor (>= 0.19.0, < 2.0) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) + 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.2.1) - rb-fsevent (0.11.2) + rdoc (6.13.0) + psych (>= 4.0.0) + reek (6.5.0) + dry-schema (~> 1.13) + logger (~> 1.6) + parser (~> 3.3.0) + rainbow (>= 2.0, < 4.0) + rexml (~> 3.1) + regexp_parser (2.10.0) + reline (0.6.0) + io-console (~> 0.5) + require_bench (1.0.4) + version_gem (>= 1.1.3, < 4) + rexml (3.4.1) + rspec-block_is_expected (1.0.6) + rubocop (1.73.2) + 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.38.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.43.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-gradual (0.3.6) + diff-lcs (>= 1.2.0, < 2.0) + diffy (~> 3.0) + parallel (~> 1.10) + rainbow (>= 2.2.2, < 4.0) + rubocop (~> 1.0) + rubocop-lts (18.2.1) + rubocop-ruby2_7 (>= 2.0.4, < 3) + standard-rubocop-lts (>= 1.0.3, < 3) + version_gem (>= 1.1.2, < 3) + rubocop-md (1.2.4) + rubocop (>= 1.45) + rubocop-packaging (0.6.0) + lint_roller (~> 1.1.0) + rubocop (>= 1.72.1, < 2.0) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-ruby2_7 (2.0.6) + rubocop-gradual (~> 0.3, >= 0.3.1) + rubocop-md (~> 1.2) + rubocop-rake (~> 0.6) + rubocop-shopify (~> 2.14) + rubocop-thread_safety (~> 0.5, >= 0.5.1) + standard-rubocop-lts (~> 1.0, >= 1.0.7) + version_gem (>= 1.1.3, < 3) + rubocop-shopify (2.16.0) + rubocop (~> 1.62) + rubocop-thread_safety (0.7.2) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-openid2 (3.1.0) version_gem (~> 1.1, >= 1.1.4) + ruby-progressbar (1.13.0) ruby-yadis (0.3.4) ruby2_keywords (0.0.5) - ruby_gntp (0.3.4) + securerandom (0.4.1) simple_po_parser (1.1.6) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - sprockets (>= 3.0.0) - sqlite3 (1.7.3) - mini_portile2 (~> 2.8.0) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (2.1.0) + rexml + simplecov (~> 0.19) + simplecov-console (0.9.3) + ansi + simplecov + terminal-table + simplecov-html (0.13.1) + simplecov-lcov (0.8.0) + simplecov-rcov (0.3.7) + simplecov (>= 0.4.1) + simplecov_json_formatter (0.1.4) + sqlite3 (2.6.0-arm64-darwin) + standard (1.47.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.73.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.7) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.7.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.24.0) + standard-rubocop-lts (1.0.10) + rspec-block_is_expected (~> 1.0, >= 1.0.5) + standard (>= 1.35.1, < 2) + standard-custom (>= 1.0.2, < 2) + standard-performance (>= 1.3.1, < 2) + version_gem (>= 1.1.4, < 3) + stringio (3.1.6) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) test-unit (3.6.7) power_assert thor (1.3.2) - thread_safe (0.3.6) timeout (0.4.3) - tzinfo (1.2.11) - thread_safe (~> 0.1) + 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) + uri (1.0.3) + useragent (0.16.11) version_gem (1.1.6) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + yard (0.9.37) yubikey (1.4.1) + zeitwerk (2.7.2) PLATFORMS arm64-darwin-22 + arm64-darwin-24 DEPENDENCIES - byebug - guard-minitest + appraisal! + benchmark (~> 0.4) + bundler-audit (~> 0.9.2) + combustion (~> 1.5) + debug (>= 1.0.0) + kettle-soup-cover (~> 1.0, >= 1.0.4) + logger masq2! - minitest - mocha + minitest (~> 5.25, >= 5.25.5) + minitest-retry (~> 0.2, >= 0.2.5) + mocha (~> 2.7, >= 2.7.1) + mutex_m (~> 0.2) mysql2 pg - rails-controller-testing - rb-fsevent - ruby_gntp + pry (~> 0.14) + rails (~> 8.0, >= 8.0.2) + rails-controller-testing (~> 1.0, >= 1.0.5) + rake (~> 13.0) + rdoc (~> 6.11) + reek (~> 6.4) + require_bench + rexml + rubocop-lts (~> 18.2, >= 18.2.1) + rubocop-packaging (~> 0.5, >= 0.5.2) sqlite3 - test-unit (~> 3.0) + standard (>= 1.35.1, != 1.42.0, != 1.41.1) + stringio (~> 3.0) + test-unit (~> 3.6, >= 3.6.7) + yard (~> 0.9, >= 0.9.37) + yard-junk (~> 0.0, >= 0.0.10)! BUNDLED WITH - 2.4.22 + 2.6.6 diff --git a/Guardfile b/Guardfile index ed7ca8d..090c29a 100644 --- a/Guardfile +++ b/Guardfile @@ -1,15 +1,15 @@ -guard 'minitest' do +guard "minitest" do # with Minitest::Unit watch(%r|^test/(.*)\/?(.*)_test\.rb|) - watch(%r|^lib/masq.*\.rb|) { "test" } - watch(%r|^test/test_helper\.rb|) { "test" } + watch(%r|^lib/masq.*\.rb|) { "test" } + watch(%r|^test/test_helper\.rb|) { "test" } # Rails watch(%r|^app/controllers/(.*)/application_controller\.rb|) { |m| "test/functional" } watch(%r|^app/controllers/(.*)/(.*)\.rb|) { |m| "test/functional/#{m[1]}/#{m[2]}_test.rb" } - watch(%r|^app/helpers/(.*)/(.*)\.rb|) { |m| "test/helpers/#{m[1]}/#{m[2]}_test.rb" } - watch(%r|^app/models/(.*)/(.*)\.rb|) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } - watch(%r|^app/mailers/(.*)/(.*)\.rb|) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } - watch(%r|^app/views/(.*)/(.*)|) { |m| "test/integration" } - watch(%r|^config/routes\.rb|) { |m| "test/integration" } + watch(%r|^app/helpers/(.*)/(.*)\.rb|) { |m| "test/helpers/#{m[1]}/#{m[2]}_test.rb" } + watch(%r|^app/models/(.*)/(.*)\.rb|) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } + watch(%r|^app/mailers/(.*)/(.*)\.rb|) { |m| "test/unit/#{m[1]}/#{m[2]}_test.rb" } + watch(%r|^app/views/(.*)/(.*)|) { |m| "test/integration" } + watch(%r|^config/routes\.rb|) { |m| "test/integration" } end diff --git a/MIT-LICENSE b/LICENSE.txt similarity index 100% rename from MIT-LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md index 3b8629a..8b2aa5c 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,16 @@ client-server communication (like requesting simple registration data). ### Introduction -`masq2` adds ORACLE database support, as well as support for +`masq2` adds ORACLE database support, as well as support for Rails 5.2, 6.0, 6.1, 7.0, 7.1, 7.2, 8.0, -which `masq` never had. +which `masq` never had. The main functionality is in the server controller, which is the endpoint for incoming OpenID requests. The server controller is supposed to only interact with relying parties a.k.a. consumer websites. It includes the OpenidServerSystem module, which provides some handy methods to access and answer OpenID requests. -#### v1 Release Breaking Change +#### v1 Release Breaking Change \[📒Also Rails 5.2+ / Serialization / Psych Caveats\] @@ -91,14 +91,21 @@ serialize :parameters, Hash so we instead switch to serializing as JSON: ```ruby +# Rails 5.2/6.0 serialize :parameters, JSON +# Rails 6.1+ +serialize :parameters, type: Hash, coder: JSON ``` If an implementation needs to continue using the serialized Hash, -you will need to override the definition by reopening the model, and adding: +you will need to override the definition by reopening the model, +and set it back to the old way! ```ruby +# Rails 5.2/6.0 serialize :parameters, Hash +# Rails 6.1+ (untested, might not work!) +serialize :parameters, type: Hash, coder: Hash ``` In addition, one of the following is also needed. @@ -106,13 +113,13 @@ In addition, one of the following is also needed. 1. Simple, but insecure fix, which reverts to previous unpatched behavior is: ```ruby - Rails.application.config.active_record.use_yaml_unsafe_load = true +Rails.application.config.active_record.use_yaml_unsafe_load = true ``` 2. More complex, and a bit less insecure fix, is to explicitly list the allowed classes to serialize: ```ruby - Rails.application.config.active_record.yaml_column_permitted_classes = [Symbol, Date, Time, HashWithIndifferentAccess] +Rails.application.config.active_record.yaml_column_permitted_classes = [Symbol, Date, Time, HashWithIndifferentAccess] ``` ### Testing diff --git a/Rakefile b/Rakefile index cf31198..80ad2dc 100644 --- a/Rakefile +++ b/Rakefile @@ -1,39 +1,80 @@ -#!/usr/bin/env rake +# frozen_string_literal: true + +require "bundler/gem_tasks" + +Bundler.require :default, :development +Combustion.path = "test/internal" +Combustion.initialize!(:all) + +APP_RAKEFILE = File.expand_path("../test/internal/Rakefile", __FILE__) +load "rails/tasks/engine.rake" + +# APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__) +# load "rails/tasks/engine.rake" +# require "masq2" + +# Setup Reek begin - require 'bundler' - require 'bundler/setup' - Bundler::GemHelper.install_tasks + require "reek/rake/task" + + Reek::Rake::Task.new do |t| + t.fail_on_error = true + t.verbose = false + t.source_files = "{app,config,db,lib,test}/**/*.rb" + end rescue LoadError - puts 'You must `gem install bundler` and `bundle install` to run rake tasks' + task(:reek) do + warn("reek is disabled") + end end +# Setup Yard begin - require 'rdoc/task' + require "yard" + + YARD::Rake::YardocTask.new(:yard) rescue LoadError - require 'rdoc/rdoc' - require 'rake/rdoctask' - RDoc::Task = Rake::RDocTask + task(:yard) do + warn("yard is disabled") + end end -RDoc::Task.new(:rdoc) do |rdoc| - rdoc.rdoc_dir = 'rdoc' - rdoc.title = 'Masq' - rdoc.options << '--line-numbers' - rdoc.rdoc_files.include('lib/**/*.rb') +# Setup RuboCop-LTS +begin + require "rubocop/lts" + Rubocop::Lts.install_tasks +rescue LoadError + task(:rubocop_gradual) do + warn("RuboCop (Gradual) is disabled") + end end -APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__) -load 'rails/tasks/engine.rake' +# Setup Kettle Soup Cover +begin + require "kettle-soup-cover" + Kettle::Soup::Cover.install_tasks +rescue LoadError + desc("alias coverage task to test (coverage unavailable)") + task(coverage: :test) +end -Bundler::GemHelper.install_tasks +# Setup stone_checksums +begin + require "stone_checksums" + GemChecksums.install_tasks +rescue LoadError + task("build:generate_checksums") do + warn("gem_checksums is not available") + end +end -require 'rake/testtask' +require "rake/testtask" namespace :test do |ns| desc "Prepare tests" task :prepare do - Rails.env = 'test' - Rake::Task['db:setup'].invoke + # Rails.env = "test" + Rake::Task["db:reset"].invoke end tests = %w(unit functional integration) @@ -41,28 +82,31 @@ namespace :test do |ns| tests.each do |type| desc "Run #{type} tests" Rake::TestTask.new(type) do |t| - t.libs << 'lib' - t.libs << 'test' + t.libs << "lib" + t.libs << "test" t.test_files = FileList["test/#{type}/**/*_test.rb"] t.verbose = false end end desc "Run all tests" - Rake::TestTask.new('all') do |t| + Rake::TestTask.new("all") do |t| files = [] tests.each { |type| files += FileList["test/#{type}/**/*_test.rb"] } - t.libs << 'lib' - t.libs << 'test' + t.libs << "lib" + t.libs << "test" t.test_files = files t.verbose = false end - end -Rake::Task['test'].clear +test_tasks = %w[test:prepare test:all] +test_tasks.push("coverage") unless ENV.fetch("CI", "").casecmp?("true") + +Rake::Task["test"].clear desc "Run tests" -task :test => %w[test:prepare test:all] +task test: test_tasks -task :default => :test \ No newline at end of file +# coverage task will open coverage in browser locally +task default: %i[coverage rubocop_gradual:autocorrect yard reek] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..91cf09e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|------------|-----------| +| 1.0.latest | ✅ | +| 0.x | ❌ | + +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. + +## Additional Support + +If you are interested in support for versions older than the latest release, +please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate, +or find other sponsorship links in the [README]. + +[README]: README.md diff --git a/app/controllers/masq/accounts_controller.rb b/app/controllers/masq/accounts_controller.rb index 9c8dad2..7160d10 100644 --- a/app/controllers/masq/accounts_controller.rb +++ b/app/controllers/masq/accounts_controller.rb @@ -1,8 +1,8 @@ module Masq class AccountsController < BaseController - before_action :check_disabled_registration, :only => [:new, :create] - before_action :login_required, :except => [:show, :new, :create, :activate, :resend_activation_email] - before_action :detect_xrds, :only => :show + before_action :check_disabled_registration, only: [:new, :create] + before_action :login_required, except: [:show, :new, :create, :activate, :resend_activation_email] + before_action :detect_xrds, only: :show def show @account = @@ -16,7 +16,7 @@ def show respond_to do |format| format.html do - response.headers['X-XRDS-Location'] = identity_url(:account => @account, :format => :xrds, :protocol => scheme) + response.headers["X-XRDS-Location"] = identity_url(account: @account, format: :xrds, protocol: scheme) end format.xrds end @@ -27,16 +27,16 @@ def new end def create - cookies.delete :auth_token + cookies.delete(:auth_token) account_params[:login] = account_params[:email] if email_as_login? signup = Signup.create_account!(account_params) if signup.succeeded? - redirect_to login_path, :notice => signup.send_activation_email? ? + redirect_to(login_path, notice: signup.send_activation_email? ? t(:thanks_for_signing_up_activation_link) : - t(:thanks_for_signing_up) + t(:thanks_for_signing_up)) else @account = signup.account - render :action => 'new' + render(action: "new") end end @@ -44,58 +44,58 @@ def update account_params.delete(:email) if email_as_login? account_params.delete(:login) - if current_account.update_attributes(account_params) - redirect_to edit_account_path(:account => current_account), :notice => t(:profile_updated) + if current_account.update(account_params) + redirect_to(edit_account_path(account: current_account), notice: t(:profile_updated)) else - render :action => 'edit' + render(action: "edit") end end def destroy - return render_404 unless Masq::Engine.config.masq['can_disable_account'] + return render_404 unless Masq::Engine.config.masq["can_disable_account"] if current_account.authenticated?(params[:confirmation_password]) current_account.disable! current_account.forget_me - cookies.delete :auth_token + cookies.delete(:auth_token) reset_session - redirect_to root_path, :notice => t(:account_disabled) + redirect_to(root_path, notice: t(:account_disabled)) else - redirect_to edit_account_path, :alert => t(:entered_password_is_wrong) + redirect_to(edit_account_path, alert: t(:entered_password_is_wrong)) end end def activate - return render_404 unless Masq::Engine.config.masq['send_activation_mail'] + return render_404 unless Masq::Engine.config.masq["send_activation_mail"] begin Account.find_and_activate!(params[:activation_code]) - redirect_to login_path, :notice => t(:account_activated_login_now) + redirect_to(login_path, notice: t(:account_activated_login_now)) rescue ArgumentError, Account::ActivationCodeNotFound - redirect_to new_account_path, :alert => t(:couldnt_find_account_with_code_create_new_one) + redirect_to(new_account_path, alert: t(:couldnt_find_account_with_code_create_new_one)) rescue Account::AlreadyActivated - redirect_to login_path, :alert => t(:account_already_activated_please_login) + redirect_to(login_path, alert: t(:account_already_activated_please_login)) end end def change_password - return render_404 unless Masq::Engine.config.masq['can_change_password'] + return render_404 unless Masq::Engine.config.masq["can_change_password"] if Account.authenticate(current_account.login, params[:old_password]) - if ((params[:password] == params[:password_confirmation]) && !params[:password_confirmation].blank?) + if (params[:password] == params[:password_confirmation]) && !params[:password_confirmation].blank? current_account.password_confirmation = params[:password_confirmation] current_account.password = params[:password] if current_account.save - redirect_to edit_account_path(:account => current_account), :notice => t(:password_has_been_changed) + redirect_to(edit_account_path(account: current_account), notice: t(:password_has_been_changed)) else - redirect_to edit_account_path, :alert => t(:sorry_password_couldnt_be_changed) + redirect_to(edit_account_path, alert: t(:sorry_password_couldnt_be_changed)) end else @old_password = params[:old_password] - redirect_to edit_account_path, :alert => t(:confirmation_of_new_password_invalid) + redirect_to(edit_account_path, alert: t(:confirmation_of_new_password_invalid)) end else - redirect_to edit_account_path, :alert => t(:old_password_incorrect) + redirect_to(edit_account_path, alert: t(:old_password_incorrect)) end end @@ -109,13 +109,13 @@ def resend_activation_email flash[:alert] = t(:account_already_activated_or_missing) end - redirect_to login_path + redirect_to(login_path) end protected def check_disabled_registration - render_404 if Masq::Engine.config.masq['disable_registration'] + render_404 if Masq::Engine.config.masq["disable_registration"] end def detect_xrds @@ -128,6 +128,5 @@ def detect_xrds def account_params @account_params ||= params.require(:account).permit(:login, :email, :password, :password_confirmation, :public_persona_id, :yubikey_mandatory) end - end end diff --git a/app/controllers/masq/base_controller.rb b/app/controllers/masq/base_controller.rb index bd46b45..22e35ce 100644 --- a/app/controllers/masq/base_controller.rb +++ b/app/controllers/masq/base_controller.rb @@ -5,21 +5,26 @@ class BaseController < ActionController::Base protect_from_forgery - rescue_from ::ActiveRecord::RecordNotFound, :with => :render_404 - rescue_from ::ActionController::InvalidAuthenticityToken, :with => :render_422 + rescue_from ::ActiveRecord::RecordNotFound, with: :render_404 + rescue_from ::ActionController::InvalidAuthenticityToken, with: :render_422 - helper_method :extract_host, :extract_login_from_identifier, :checkid_request, - :identifier, :endpoint_url, :scheme, :email_as_login? + helper_method :extract_host, + :extract_login_from_identifier, + :checkid_request, + :identifier, + :endpoint_url, + :scheme, + :email_as_login? protected def endpoint_url - server_url(:protocol => scheme) + server_url(protocol: scheme) end # Returns the OpenID identifier for an account def identifier(account) - identity_url(:account => account, :protocol => scheme) + identity_url(account: account, protocol: scheme) end # Extracts the hostname from the given url, which is used to @@ -29,11 +34,11 @@ def extract_host(u) end def extract_login_from_identifier(openid_url) - openid_url.gsub(/^https?:\/\/.*\//, '') + openid_url.gsub(/^https?:\/\/.*\//, "") end def checkid_request - unless (@checkid_request||=nil) + unless @checkid_request ||= nil req = openid_server.decode_request(current_openid_request.parameters) if current_openid_request @checkid_request = req.is_a?(OpenID::Server::CheckIDRequest) ? req : false end @@ -57,17 +62,17 @@ def render_500 end def render_error(status_code) - render :file => "#{Rails.root}/public/#{status_code}", :formats => [:html], :status => status_code, :layout => false + render(file: "#{Rails.root}/public/#{status_code}.html", formats: [:html], status: status_code, layout: false) end private def scheme - Masq::Engine.config.masq['use_ssl'] ? 'https' : 'http' + Masq::Engine.config.masq["use_ssl"] ? "https" : "http" end def email_as_login? - Masq::Engine.config.masq['email_as_login'] + Masq::Engine.config.masq["email_as_login"] end end end diff --git a/app/controllers/masq/consumer_controller.rb b/app/controllers/masq/consumer_controller.rb index a770440..10b9c3c 100644 --- a/app/controllers/masq/consumer_controller.rb +++ b/app/controllers/masq/consumer_controller.rb @@ -1,130 +1,137 @@ module Masq class ConsumerController < BaseController - skip_before_action :verify_authenticity_token + def index + end + def start begin - oidreq = openid_consumer.begin(params[:openid_identifier]) + open_id_req = openid_consumer.begin(params[:openid_identifier]) rescue OpenID::OpenIDError => e - redirect_to consumer_path, :alert => "Discovery failed for #{params[:openid_identifier]}: #{e}" + redirect_to(consumer_path, alert: "Discovery failed for #{params[:openid_identifier]}: #{e}") return end if params[:use_sreg] - sregreq = OpenID::SReg::Request.new - sregreq.policy_url = 'http://www.policy-url.com' - sregreq.request_fields(['nickname', 'email'], true) # required - sregreq.request_fields(['fullname', 'dob'], false) # optional - oidreq.add_extension(sregreq) - oidreq.return_to_args['did_sreg'] = 'y' + open_id_sreg_req = OpenID::SReg::Request.new + open_id_sreg_req.policy_url = "http://www.policy-url.com" + open_id_sreg_req.request_fields(["nickname", "email"], true) # required + open_id_sreg_req.request_fields(["fullname", "dob"], false) # optional + open_id_req.add_extension(open_id_sreg_req) + open_id_req.return_to_args["did_sreg"] = "y" end if params[:use_ax_fetch] axreq = OpenID::AX::FetchRequest.new - requested_attrs = [['https://openid.tzi.de/spec/schema', 'uid', true], - ['http://axschema.org/namePerson/friendly', 'nickname', true], - ['http://axschema.org/contact/email', 'email', true], - ['http://axschema.org/namePerson', 'fullname'], - ['http://axschema.org/contact/web/default', 'website', false, 2], - ['http://axschema.org/contact/postalCode/home', 'postcode'], - ['http://axschema.org/person/gender', 'gender'], - ['http://axschema.org/birthDate', 'birth_date'], - ['http://axschema.org/contact/country/home', 'country'], - ['http://axschema.org/pref/language', 'language'], - ['http://axschema.org/pref/timezone', 'timezone']] + requested_attrs = [ + ["https://openid.tzi.de/spec/schema", "uid", true], + ["http://axschema.org/namePerson/friendly", "nickname", true], + ["http://axschema.org/contact/email", "email", true], + ["http://axschema.org/namePerson", "fullname"], + ["http://axschema.org/contact/web/default", "website", false, 2], + ["http://axschema.org/contact/postalCode/home", "postcode"], + ["http://axschema.org/person/gender", "gender"], + ["http://axschema.org/birthDate", "birth_date"], + ["http://axschema.org/contact/country/home", "country"], + ["http://axschema.org/pref/language", "language"], + ["http://axschema.org/pref/timezone", "timezone"], + ] requested_attrs.each { |a| axreq.add(OpenID::AX::AttrInfo.new(a[0], a[1], a[2] || false, a[3] || 1)) } - oidreq.add_extension(axreq) - oidreq.return_to_args['did_ax_fetch'] = 'y' + open_id_req.add_extension(axreq) + open_id_req.return_to_args["did_ax_fetch"] = "y" end if params[:use_ax_store] ax_store_req = OpenID::AX::StoreRequest.new - ax_store_req.set_values('http://axschema.org/contact/email', %w(email@example.com)) - ax_store_req.set_values('http://axschema.org/birthDate', %w(1976-08-07)) - ax_store_req.set_values('http://axschema.org/customValueThatIsNotSupported', %w(unsupported)) - oidreq.add_extension(ax_store_req) - oidreq.return_to_args['did_ax_store'] = 'y' + ax_store_req.set_values("http://axschema.org/contact/email", %w(email@example.com)) + ax_store_req.set_values("http://axschema.org/birthDate", %w(1976-08-07)) + ax_store_req.set_values("http://axschema.org/customValueThatIsNotSupported", %w(unsupported)) + open_id_req.add_extension(ax_store_req) + open_id_req.return_to_args["did_ax_store"] = "y" end if params[:use_pape] - papereq = OpenID::PAPE::Request.new - papereq.add_policy_uri(OpenID::PAPE::AUTH_PHISHING_RESISTANT) - papereq.max_auth_age = 60 - oidreq.add_extension(papereq) - oidreq.return_to_args['did_pape'] = 'y' + open_id_pape_req = OpenID::PAPE::Request.new + open_id_pape_req.add_policy_uri(OpenID::PAPE::AUTH_PHISHING_RESISTANT) + open_id_pape_req.max_auth_age = 60 + open_id_req.add_extension(open_id_pape_req) + open_id_req.return_to_args["did_pape"] = "y" end if params[:force_post] - oidreq.return_to_args['force_post'] = 'x' * 2048 + open_id_req.return_to_args["force_post"] = "x" * 2048 end - if oidreq.send_redirect?(consumer_url, consumer_complete_url, params[:immediate]) - redirect_to oidreq.redirect_url(consumer_url, consumer_complete_url, params[:immediate]) + if open_id_req.send_redirect?(consumer_url, consumer_complete_url, params[:immediate]) + redirect_to(open_id_req.redirect_url(consumer_url, consumer_complete_url, params[:immediate])) else - @form_text = oidreq.form_markup(consumer_url, consumer_complete_url, params[:immediate], { 'id' => 'checkid_form' }) + @form_text = open_id_req.form_markup(consumer_url, consumer_complete_url, params[:immediate], {"id" => "checkid_form"}) end end def complete - parameters = params.to_unsafe_h.reject{ |k,v| request.path_parameters[k.to_sym] } - oidresp = openid_consumer.complete(parameters, url_for({})) - case oidresp.status + parameters = params.to_unsafe_h.reject { |k, _v| request.path_parameters[k.to_sym] } + open_id_req = openid_consumer.complete(parameters, url_for({})) + case open_id_req.status when OpenID::Consumer::SETUP_NEEDED flash[:alert] = t(:immediate_request_failed_setup_needed) when OpenID::Consumer::CANCEL flash[:alert] = t(:openid_transaction_cancelled) when OpenID::Consumer::FAILURE - flash[:alert] = oidresp.display_identifier ? - t(:verification_of_identifier_failed, :identifier => oidresp.display_identifier, :message => oidresp.message) : - t(:verification_failed_message, :message => oidresp.message) + flash[:alert] = open_id_req.display_identifier ? + t(:verification_of_identifier_failed, identifier: open_id_req.display_identifier, message: open_id_req.message) : + t(:verification_failed_message, message: open_id_req.message) when OpenID::Consumer::SUCCESS - flash[:notice] = t(:verification_of_identifier_succeeded, :identifier => oidresp.display_identifier) + flash[:notice] = t(:verification_of_identifier_succeeded, identifier: open_id_req.display_identifier) if params[:did_sreg] - sreg_resp = OpenID::SReg::Response.from_success_response(oidresp) + sreg_resp = OpenID::SReg::Response.from_success_response(open_id_req) sreg_message = "\n\n" + t(:simple_registration_data_requested) if sreg_resp.empty? sreg_message << ", " + t(:but_none_was_returned) else sreg_message << ". " + t(:the_following_data_were_sent) + "\n" - sreg_resp.data.each { |k,v| sreg_message << "#{k}: #{v}\n" } + sreg_resp.data.each { |k, v| sreg_message << "#{k}: #{v}\n" } end flash[:notice] += sreg_message end if params[:did_ax_fetch] - ax_fetch_resp = OpenID::AX::FetchResponse.from_success_response(oidresp) + ax_fetch_resp = OpenID::AX::FetchResponse.from_success_response(open_id_req) ax_fetch_message = "\n\n" + t(:attribute_exchange_data_requested) - unless ax_fetch_resp - ax_fetch_message << ", " + t(:but_none_was_returned) - else + if ax_fetch_resp ax_fetch_message << ". " + t(:the_following_data_were_sent) + "\n" - ax_fetch_resp.data.each { |k,v| ax_fetch_message << "#{k}: #{v}\n" } + ax_fetch_resp.data.each { |k, v| ax_fetch_message << "#{k}: #{v}\n" } + else + ax_fetch_message << ", " + t(:but_none_was_returned) end flash[:notice] += ax_fetch_message end if params[:did_ax_store] - ax_store_resp = OpenID::AX::StoreResponse.from_success_response(oidresp) + ax_store_resp = OpenID::AX::StoreResponse.from_success_response(open_id_req) ax_store_message = "\n\n" + t(:attribute_exchange_store_requested) - unless ax_store_resp - ax_store_message << ", " + t(:but_got_no_response) - else + ax_store_message << if ax_store_resp if ax_store_resp.succeeded? - ax_store_message << " " + t(:and_saved_at_the_identity_provider) + " " + t(:and_saved_at_the_identity_provider) else - ax_store_message << ", " + t(:but_an_error_occured, :error_message => ax_store_resp.error_message) + ", " + t(:but_an_error_occured, error_message: ax_store_resp.error_message) end + else + ", " + t(:but_got_no_response) end flash[:notice] += ax_store_message end if params[:did_pape] - pape_resp = OpenID::PAPE::Response.from_success_response(oidresp) + pape_resp = OpenID::PAPE::Response.from_success_response(open_id_req) pape_message = "\n\n" + t(:authentication_policies_requested) - unless pape_resp.auth_policies.empty? + if pape_resp.auth_policies.empty? + pape_message << ", " + t(:but_the_server_did_not_report_one) + else pape_message << ", " + t(:and_server_reported_the_following) + "\n" pape_resp.auth_policies.each { |p| pape_message << "#{p}\n" } - else - pape_message << ", " + t(:but_the_server_did_not_report_one) end pape_message << "\n" + t(:authentication_time) + ": #{pape_resp.auth_time}" if pape_resp.auth_time pape_message << "\nNIST Auth Level: #{pape_resp.nist_auth_level}" if pape_resp.nist_auth_level flash[:notice] += pape_message end + else + # NOOP + # This should never happen. end - redirect_to :action => 'index' + redirect_to(action: "index") end private diff --git a/app/controllers/masq/info_controller.rb b/app/controllers/masq/info_controller.rb index 17f8afc..e00d65b 100644 --- a/app/controllers/masq/info_controller.rb +++ b/app/controllers/masq/info_controller.rb @@ -3,17 +3,17 @@ class InfoController < BaseController # The yadis discovery header tells incoming OpenID # requests where to find the server endpoint. def index - response.headers['X-XRDS-Location'] = server_url(:format => :xrds, :protocol => scheme) + response.headers["X-XRDS-Location"] = server_url(format: :xrds, protocol: scheme) end # This page is to prevent phishing attacks. It should # not contain any links, the user has to navigate to # the right login page manually. def safe_login - if not Masq::Engine.config.masq.include? 'protect_phishing' or Masq::Engine.config.masq['protect_phishing'] - render :layout => false + if !Masq::Engine.config.masq.include?("protect_phishing") or Masq::Engine.config.masq["protect_phishing"] + render(layout: false) else - redirect_to login_url + redirect_to(login_url) end end diff --git a/app/controllers/masq/passwords_controller.rb b/app/controllers/masq/passwords_controller.rb index 77b4f84..0560158 100644 --- a/app/controllers/masq/passwords_controller.rb +++ b/app/controllers/masq/passwords_controller.rb @@ -1,31 +1,29 @@ module Masq class PasswordsController < BaseController - before_action :check_can_change_password, :only => [:create, :edit, :update] - before_action :find_account_by_reset_code, :only => [:edit, :update] + before_action :check_can_change_password, only: [:create, :edit, :update] + before_action :find_account_by_reset_code, only: [:edit, :update] # Forgot password def create if account = Account.find_by(email: params[:email], activation_code: nil) account.forgot_password! - redirect_to login_path, :notice => t(:password_reset_link_has_been_sent) + redirect_to(login_path, notice: t(:password_reset_link_has_been_sent)) else flash[:alert] = t(:could_not_find_user_with_email_address) - render :action => 'new' + render(action: "new") end end # Reset password def update - unless params[:password].blank? - if @account.update_attributes(:password => params[:password], :password_confirmation => params[:password_confirmation]) - redirect_to login_path, :notice => t(:password_reset) - else - flash[:alert] = t(:password_mismatch) - render :action => 'edit' - end - else + if params[:password].blank? flash[:alert] = t(:password_cannot_be_blank) - render :action => 'edit' + render(action: "edit") + elsif @account.update(password: params[:password], password_confirmation: params[:password_confirmation]) + redirect_to(login_path, notice: t(:password_reset)) + else + flash[:alert] = t(:password_mismatch) + render(action: "edit") end end @@ -34,11 +32,11 @@ def update def find_account_by_reset_code @reset_code = params[:id] @account = @reset_code.blank? ? nil : Account.find_by(password_reset_code: @reset_code) - redirect_to(forgot_password_path, :alert => t(:reset_code_invalid_try_again)) unless @account + redirect_to(forgot_password_path, alert: t(:reset_code_invalid_try_again)) unless @account end def check_can_change_password - render_404 unless Masq::Engine.config.masq['can_change_password'] + render_404 unless Masq::Engine.config.masq["can_change_password"] end end end diff --git a/app/controllers/masq/personas_controller.rb b/app/controllers/masq/personas_controller.rb index 341dc69..8514800 100644 --- a/app/controllers/masq/personas_controller.rb +++ b/app/controllers/masq/personas_controller.rb @@ -1,7 +1,7 @@ module Masq class PersonasController < BaseController before_action :login_required - before_action :store_return_url, :only => [:new, :edit] + before_action :store_return_url, only: [:new, :edit] helper_method :persona @@ -17,25 +17,24 @@ def new @persona = current_account.personas.new end - def create respond_to do |format| if persona.save! flash[:notice] = t(:persona_successfully_created) - format.html { redirect_back_or_default account_personas_path } + format.html { redirect_back_or_default(account_personas_path) } else - format.html { render :action => "new" } + format.html { render(action: "new") } end end end def update respond_to do |format| - if persona.update_attributes(persona_params) + if persona.update(persona_params) flash[:notice] = t(:persona_updated) - format.html { redirect_back_or_default account_personas_path } + format.html { redirect_back_or_default(account_personas_path) } else - format.html { render :action => "edit" } + format.html { render(action: "edit") } end end end @@ -45,7 +44,7 @@ def destroy unless persona.destroy flash[:alert] = t(:persona_cannot_be_deleted) end - format.html { redirect_to account_personas_path } + format.html { redirect_to(account_personas_path) } end end @@ -60,12 +59,12 @@ def persona def persona_params rejected_keys = [:created_at, :updated_at, :account_id, :deletable] params.require(:persona).permit!.except(rejected_keys) - end + end def redirect_back_or_default(default) case session[:return_to] - when decide_path then redirect_to decide_path(:persona_id => persona.id) - else super(default) + when decide_path then redirect_to(decide_path(persona_id: persona.id)) + else super end end diff --git a/app/controllers/masq/server_controller.rb b/app/controllers/masq/server_controller.rb index b6d5965..84eeb19 100644 --- a/app/controllers/masq/server_controller.rb +++ b/app/controllers/masq/server_controller.rb @@ -5,7 +5,7 @@ class ServerController < BaseController skip_before_action :verify_authenticity_token # Error handling rescue_from OpenID::Server::ProtocolError, with: :render_openid_error - # Actions other than index require a logged in user + # Actions other than index require a logged-in user before_action :login_required, except: %i[index cancel seatbelt_config seatbelt_login_state] before_action :ensure_valid_checkid_request, except: %i[index cancel seatbelt_config seatbelt_login_state] after_action :clear_checkid_request, only: %i[cancel complete] @@ -26,7 +26,7 @@ def index elsif openid_request handle_non_checkid_request else - render :plain => t(:this_is_openid_not_a_human_ressource) + render(plain: t(:this_is_openid_not_a_human_resource)) end end format.xrds @@ -40,11 +40,12 @@ def index # be answered based on the users release policy. If the request is immediate # (relying party wants no user interaction, used e.g. for ajax requests) # the request can only be answered if no further information (like simple - # registration data) is requested. Otherwise the user will be redirected + # registration data) is requested. Otherwise, the user will be redirected # to the decision page. def proceed identity = identifier(current_account) - if @site = current_account.sites.find_by(url: checkid_request.trust_root) + @site = current_account.sites.find_by(url: checkid_request.trust_root) + if @site resp = checkid_request.answer(true, nil, identity) resp = add_sreg(resp, @site.sreg_properties) if sreg_request resp = add_ax(resp, @site.ax_properties) if ax_fetch_request @@ -55,12 +56,12 @@ def proceed elsif checkid_request.immediate render_response(checkid_request.answer(true, nil, identity)) else - redirect_to decide_path + redirect_to(decide_path) end end # Displays the decision page on that the user can confirm the request and - # choose which data should be transfered to the relying party. + # choose which data should be transferred to the relying party. def decide @site = current_account.sites.where(url: checkid_request.trust_root).first_or_initialize @site.persona = current_account.personas.find_by(params[:persona_id]) || current_account.personas.first if sreg_request || ax_store_request || ax_fetch_request @@ -68,7 +69,7 @@ def decide # This action is called by submitting the decision form, the information entered by # the user is used to answer the request. If the user decides to always trust the - # relying party, a new site according to the release policies the will be created. + # relying party, a new site according to the release policies will be created. def complete if params[:cancel] cancel @@ -76,7 +77,7 @@ def complete resp = checkid_request.answer(true, nil, identifier(current_account)) if params[:always] @site = current_account.sites.where(persona_id: params[:site][:persona_id], url: params[:site][:url]).first_or_create - @site.update_attributes(site_params) + @site.update(site_params) elsif sreg_request || ax_fetch_request @site = current_account.sites.where(persona_id: params[:site][:persona_id], url: params[:site][:url]).first_or_create @site.attributes = site_params @@ -86,7 +87,8 @@ def complete not_accepted = [] accepted = [] ax_store_request.data.each do |type_uri, values| - if property = Persona.attribute_name_for_type_uri(type_uri) + property = Persona.attribute_name_for_type_uri(type_uri) + if property store_attribute = params[:site][:ax_store][property.to_sym] if store_attribute && !store_attribute[:value].blank? @site.persona.update_attribute(property, values.first) @@ -111,7 +113,7 @@ def complete # Cancels the current OpenID request def cancel if checkid_request - redirect_to checkid_request.cancel_url + redirect_to(checkid_request.cancel_url) else reset_session redirect_to(login_path) @@ -128,7 +130,7 @@ def cancel def handle_checkid_request if allow_verification? save_checkid_request - redirect_to proceed_path + redirect_to(proceed_path) elsif openid_request.immediate render_response(openid_request.answer(false)) else @@ -152,7 +154,7 @@ def save_checkid_request # Deletes the old request when a new one comes in. def clear_checkid_request unless session[:request_token].blank? - OpenIdRequest.where(:token => session[:request_token]).destroy_all + OpenIdRequest.where(token: session[:request_token]).destroy_all session[:request_token] = nil end end @@ -164,13 +166,13 @@ def clear_checkid_request def ensure_valid_checkid_request self.openid_request = checkid_request if !openid_request.is_a?(OpenID::Server::CheckIDRequest) - redirect_to root_path, alert: t(:identity_verification_request_invalid) + redirect_to(root_path, alert: t(:identity_verification_request_invalid)) elsif !allow_verification? - flash[:notice] = logged_in? && !pape_requirements_met?(auth_time) ? + flash[:notice] = (logged_in? && !pape_requirements_met?(auth_time)) ? t(:service_provider_requires_reauthentication_last_login_too_long_ago) : t(:login_to_verify_identity) session[:return_to] = proceed_path - redirect_to login_path + redirect_to(login_path) end end @@ -184,7 +186,7 @@ def allow_verification? # must be logged in, so that we know his identifier or the identifier # has to be selected by the server (id_select). def correct_identifier? - (openid_request.identity == identifier(current_account) || openid_request.id_select) + openid_request.identity == identifier(current_account) || openid_request.id_select end # Clears the stored request and answers @@ -197,9 +199,9 @@ def render_response(resp) def transform_ax_data(parameters) data = {} parameters.each_pair do |key, details| - if details['value'] - data["type.#{key}"] = details['type'] - data["value.#{key}"] = details['value'] + if details["value"] + data["type.#{key}"] = details["type"] + data["value.#{key}"] = details["value"] end end data @@ -208,10 +210,10 @@ def transform_ax_data(parameters) # Renders the exception message as text output def render_openid_error(exception) error = case exception - when OpenID::Server::MalformedTrustRoot then "Malformed trust root '#{exception}'" - else exception.to_s + when OpenID::Server::MalformedTrustRoot then "Malformed trust root '#{exception}'" + else exception.to_s end - render plain: "Invalid OpenID request: #{error}", status: 500 + render(plain: "Invalid OpenID request: #{error}", status: 500) end private @@ -219,7 +221,7 @@ def render_openid_error(exception) # The NIST Assurance Level, see: # http://openid.net/specs/openid-provider-authentication-policy-extension-1_0-01.html#anchor12 def auth_level - if Masq::Engine.config.masq['use_ssl'] + if Masq::Engine.config.masq["use_ssl"] current_account.last_authenticated_by_yubikey? ? 3 : 2 else 0 diff --git a/app/controllers/masq/sessions_controller.rb b/app/controllers/masq/sessions_controller.rb index 769d84a..eb9f047 100644 --- a/app/controllers/masq/sessions_controller.rb +++ b/app/controllers/masq/sessions_controller.rb @@ -1,55 +1,57 @@ module Masq class SessionsController < BaseController - before_action :login_required, :only => :destroy - after_action :set_login_cookie, :only => :create + before_action :login_required, only: :destroy + after_action :set_login_cookie, only: :create def new redirect_after_login if logged_in? end def create - self.current_account = Account.authenticate(params[:login], params[:password]) + self.current_account = Masq::Account.authenticate(params[:login], params[:password]) if logged_in? flash[:notice] = t(:you_are_logged_in) redirect_after_login else - a = Account.find_by(login: params[:login]) + a = Masq::Account.find_by(login: params[:login]) if a.nil? - redirect_to login_path, :alert => t(:login_incorrect) + redirect_to(login_path(error: "incorrect-login"), alert: t(:login_incorrect)) elsif a.active? && a.enabled? - redirect_to login_path, :alert => t(:password_incorrect) - elsif not a.enabled? - redirect_to login_path, :alert => t(:account_deactivated) + redirect_to(login_path(error: "incorrect-password"), alert: t(:password_incorrect)) + elsif !a.enabled? + redirect_to(login_path(error: "deactivated"), alert: t(:account_deactivated)) else - redirect_to login_path(:resend_activation_for => params[:login]), :alert => t(:account_not_yet_activated) + redirect_to(login_path(resend_activation_for: params[:login]), alert: t(:account_not_yet_activated)) end end end def destroy current_account.forget_me - cookies.delete :auth_token + cookies.delete(:auth_token) reset_session - redirect_to root_path, :notice => t(:you_are_now_logged_out) + redirect_to(root_path, notice: t(:you_are_now_logged_out)) end private def set_login_cookie - if logged_in? and params[:remember_me] == '1' + if logged_in? and params[:remember_me] == "1" current_account.remember_me cookies[:auth_token] = { - :value => current_account.remember_token, - :expires => current_account.remember_token_expires_at } + value: current_account.remember_token, + expires: current_account.remember_token_expires_at, + } end end def redirect_after_login - if return_to = session[:return_to] + return_to = session[:return_to] + if return_to session[:return_to] = nil - redirect_to return_to + redirect_to(return_to) else - redirect_to identifier(current_account) + redirect_to(identifier(current_account)) end end end diff --git a/app/controllers/masq/sites_controller.rb b/app/controllers/masq/sites_controller.rb index 88961b3..328b742 100644 --- a/app/controllers/masq/sites_controller.rb +++ b/app/controllers/masq/sites_controller.rb @@ -1,7 +1,7 @@ module Masq class SitesController < BaseController before_action :login_required - before_action :find_personas, :only => [:create, :edit, :update] + before_action :find_personas, only: [:create, :edit, :update] helper_method :site, :persona @@ -19,11 +19,11 @@ def edit def update respond_to do |format| - if site.update_attributes(site_params) + if site.update(site_params) flash[:notice] = t(:release_policy_for_site_updated) - format.html { redirect_to edit_account_site_path(site) } + format.html { redirect_to(edit_account_site_path(site)) } else - format.html { render :action => 'edit' } + format.html { render(action: "edit") } end end end @@ -32,10 +32,13 @@ def destroy site.destroy respond_to do |format| - format.html { redirect_to account_sites_path } + format.html { redirect_to(account_sites_path) } end end + def create + end + private def site @@ -51,7 +54,7 @@ def find_personas end def site_params - params.require(:site).permit! + params.require(:site).permit(:persona_id, :url, properties: {}) end end end diff --git a/app/controllers/masq/yubikey_associations_controller.rb b/app/controllers/masq/yubikey_associations_controller.rb index 24ac314..6aa64ca 100644 --- a/app/controllers/masq/yubikey_associations_controller.rb +++ b/app/controllers/masq/yubikey_associations_controller.rb @@ -9,7 +9,7 @@ def create flash[:alert] = t(:sorry_yubico_one_time_password_incorrect) end respond_to do |format| - format.html { redirect_to edit_account_path } + format.html { redirect_to(edit_account_path) } end end @@ -18,7 +18,7 @@ def destroy current_account.save respond_to do |format| - format.html { redirect_to edit_account_path, :notice => t(:account_disassociated_from_yubico_identity) } + format.html { redirect_to(edit_account_path, notice: t(:account_disassociated_from_yubico_identity)) } end end end diff --git a/app/helpers/masq/application_helper.rb b/app/helpers/masq/application_helper.rb index 6e7941a..9050f45 100644 --- a/app/helpers/masq/application_helper.rb +++ b/app/helpers/masq/application_helper.rb @@ -1,28 +1,27 @@ module Masq - module ApplicationHelper def page_title - (@page_title||=nil) ? "#{@page_title} | #{Masq::Engine.config.masq['name']}" : Masq::Engine.config.masq['name'] + (@page_title ||= nil) ? "#{@page_title} | #{Masq::Engine.config.masq["name"]}" : Masq::Engine.config.masq["name"] end def label_tag(field, text = nil, options = {}) - content_tag :label, text ? text : field.to_s.humanize, options.reverse_merge(:for => field.to_s) + content_tag(:label, text ? text : field.to_s.humanize, options.reverse_merge(for: field.to_s)) end def error_messages_for(*objects) - render "masq/shared/error_messages", :objects => objects.flatten + render("masq/shared/error_messages", objects: objects.flatten) end # Is the current page an identity page? This is used to display # further information (like the endoint url) in the def identity_page? - active_page? 'accounts' => ['show'] + active_page?("accounts" => ["show"]) end # Is the current page the home page? This is used to display # further information (like the endoint url) in the def home_page? - active_page? 'info' => ['index'] + active_page?("info" => ["index"]) end # Custom label names for request properties (like SReg data) @@ -43,7 +42,7 @@ def property_label_text_for_type_uri(type_uri) # Renders a navigation element and marks it as active where # appropriate. See active_page? for details def nav(name, url, pages = nil, active = false) - content_tag :li, link_to(name, url), :class => (active || (pages && active_page?(pages)) ? 'act' : nil) + content_tag(:li, link_to(name, url), class: ((active || (pages && active_page?(pages))) ? "act" : nil)) end # Takes a hash with pages and tells whether the current page is among them. diff --git a/app/helpers/masq/personas_helper.rb b/app/helpers/masq/personas_helper.rb index 2291fa5..f93302b 100644 --- a/app/helpers/masq/personas_helper.rb +++ b/app/helpers/masq/personas_helper.rb @@ -1,15 +1,15 @@ -require 'i18n_data' +require "i18n_data" module Masq module PersonasHelper # get list of codes and names sorted by country name def countries_for_select - ::I18nData.countries.map{|pair| pair.reverse}.sort{|x,y| x.first <=> y.first} + ::I18nData.countries.map { |pair| pair.reverse }.sort_by(&:first) end # get list of codes and names sorted by language name def languages_for_select - ::I18nData.languages.map{|pair| pair.reverse}.sort{|x,y| x.first <=> y.first} + ::I18nData.languages.map { |pair| pair.reverse }.sort_by(&:first) end end end diff --git a/app/mailers/masq/account_mailer.rb b/app/mailers/masq/account_mailer.rb index 05ce898..2ad9db2 100644 --- a/app/mailers/masq/account_mailer.rb +++ b/app/mailers/masq/account_mailer.rb @@ -1,17 +1,17 @@ module Masq class AccountMailer < ActionMailer::Base - default :from => Masq::Engine.config.masq['email'] - default_url_options[:host] = Masq::Engine.config.masq['host'] + default from: Masq::Engine.config.masq["email"] + default_url_options[:host] = Masq::Engine.config.masq["host"] def signup_notification(account) - raise "send_activation_mail deactivated" unless Masq::Engine.config.masq['send_activation_mail'] + raise "send_activation_mail deactivated" unless Masq::Engine.config.masq["send_activation_mail"] @account = account - mail :to => account.email, :subject => I18n.t(:please_activate_your_account) + mail(to: account.email, subject: I18n.t(:please_activate_your_account)) end def forgot_password(account) @account = account - mail :to => account.email, :subject => I18n.t(:your_request_for_a_new_password) + mail(to: account.email, subject: I18n.t(:your_request_for_a_new_password)) end end end diff --git a/app/models/masq/account.rb b/app/models/masq/account.rb index 71f293b..7abfd4d 100644 --- a/app/models/masq/account.rb +++ b/app/models/masq/account.rb @@ -1,51 +1,111 @@ -require 'digest/sha1' +require "digest/sha1" module Masq class Account < ActiveRecord::Base - has_many :personas, ->(){order(:id)}, :dependent => :delete_all - has_many :sites, :dependent => :destroy - belongs_to :public_persona, :class_name => "Persona", optional: true + has_many :personas, ->() { order(:id) }, dependent: :delete_all + has_many :sites, dependent: :destroy + belongs_to :public_persona, class_name: "Persona", optional: true validates_presence_of :login - validates_length_of :login, :within => 3..254 - validates_uniqueness_of :login, :case_sensitive => false - validates_format_of :login, :with => /\A[A-Za-z0-9_@.-]+\z/ + validates_length_of :login, within: 3..254 + validates_uniqueness_of :login, case_sensitive: false + validates_format_of :login, with: /\A[A-Za-z0-9_@.-]+\z/ validates_presence_of :email - validates_uniqueness_of :email, :case_sensitive => false - validates_format_of :email, :with => /(\A([^@\s]+)@((?:[-_a-z0-9]+\.)+[a-z]{2,})\z)/i, :allow_blank => true - validates_presence_of :password, :if => :password_required? - validates_presence_of :password_confirmation, :if => :password_required? - validates_length_of :password, :within => 6..40, :if => :password_required? - validates_confirmation_of :password, :if => :password_required? + validates_uniqueness_of :email, case_sensitive: false + validates_format_of :email, with: /(\A([^@\s]+)@((?:[-_a-z0-9]+\.)+[a-z]{2,})\z)/i, allow_blank: true + validates_presence_of :password, if: :password_required? + validates_presence_of :password_confirmation, if: :password_required? + validates_length_of :password, within: 6..40, if: :password_required? + validates_confirmation_of :password, if: :password_required? # check `rake routes' for whether this list is still complete when routes are changed - validates_exclusion_of :login, :in => %w[account session password help safe-login forgot_password reset_password login logout server consumer] + validates_exclusion_of :login, in: %w[account session password help safe-login forgot_password reset_password login logout server consumer] - before_save :encrypt_password - after_save :deliver_forgot_password + before_save :encrypt_password + after_save :deliver_forgot_password - #attr_accessible :login, :email, :password, :password_confirmation, :public_persona_id, :yubikey_mandatory + # attr_accessible :login, :email, :password, :password_confirmation, :public_persona_id, :yubikey_mandatory attr_accessor :password class ActivationCodeNotFound < StandardError; end + class AlreadyActivated < StandardError attr_reader :user, :message - def initialize(account, message=nil) + def initialize(account, message = nil) @message, @account = message, account end end - # Finds the user with the corresponding activation code, activates their account and returns the user. - # - # Raises: - # [Account::ActivationCodeNotFound] if there is no user with the corresponding activation code - # [Account::AlreadyActivated] if the user with the corresponding activation code has already activated their account - def self.find_and_activate!(activation_code) - raise ArgumentError if activation_code.nil? - user = find_by(activation_code: activation_code) - raise ActivationCodeNotFound unless user - raise AlreadyActivated.new(user) if user.active? - user.send(:activate!) - user + class << self + # Finds the user with the corresponding activation code, activates their account and returns the user. + # + # Raises: + # [Account::ActivationCodeNotFound] if there is no user with the corresponding activation code + # [Account::AlreadyActivated] if the user with the corresponding activation code has already activated their account + def find_and_activate!(activation_code) + raise ArgumentError if activation_code.nil? + user = find_by(activation_code: activation_code) + raise ActivationCodeNotFound unless user + raise AlreadyActivated.new(user) if user.active? + user.send(:activate!) + user + end + + # Authenticates a user by their login name and password. + # Returns the user or nil. + def authenticate(login, password, basic_auth_used = false) + a = find_by(login: login) + if a.nil? && Masq::Engine.config.masq["create_auth_ondemand"]["enabled"] + # Need to set some password - but is never used + pw = if Masq::Engine.config.masq["create_auth_ondemand"]["random_password"] + SecureRandom.hex(13) + else + password + end + signup = Masq::Signup.create_account!( + login: login, + password: pw, + password_confirmation: pw, + email: "#{login}@#{Masq::Engine.config.masq["create_auth_ondemand"]["default_mail_domain"]}", + ) + a = signup.account if signup.succeeded? + end + + if !a.nil? && a.active? && a.enabled + if a.authenticated?(password) || (Masq::Engine.config.masq["trust_basic_auth"] && basic_auth_used) + a.last_authenticated_at = Time.now.utc + a.last_authenticated_by_yubikey = a.authenticated_with_yubikey? + a.save(validate: false) + a + end + end + end + + # Encrypts some data with the salt. + def encrypt(password, salt) + Digest::SHA1.hexdigest("--#{salt}--#{password}--") + end + + # Receives a login token which consists of the users password and + # a Yubico one time password (the otp is always 44 characters long) + def split_password_and_yubico_otp(token) + token.reverse! + yubico_otp = token.slice!(0..43).reverse + password = token.reverse + [password, yubico_otp] + end + + # Returns the first twelve chars from the Yubico OTP, + # which are used to identify a Yubikey + def extract_yubico_identity_from_otp(yubico_otp) + yubico_otp[0..11] + end + + # Utilizes the Yubico library to verify a one time password + def verify_yubico_otp(otp) + Yubikey::OTP::Verify.new(otp).valid? + rescue Yubikey::OTP::InvalidOTPError + false + end end def to_param @@ -61,7 +121,7 @@ def activate! @activated = true self.activated_at = Time.now.utc self.activation_code = nil - self.save + save end # True if the user has just been activated @@ -74,39 +134,6 @@ def has_otp_device? !yubico_identity.nil? end - # Authenticates a user by their login name and password. - # Returns the user or nil. - def self.authenticate(login, password, basic_auth_used=false) - a = Account.find_by(login: login) - if a.nil? and Masq::Engine.config.masq['create_auth_ondemand']['enabled'] - # Need to set some password - but is never used - if Masq::Engine.config.masq['create_auth_ondemand']['random_password'] - pw = SecureRandom.hex(13) - else - pw = password - end - signup = Signup.create_account!( - :login => login, - :password => pw, - :password_confirmation => pw, - :email => "#{login}@#{Masq::Engine.config.masq['create_auth_ondemand']['default_mail_domain']}") - a = signup.account if signup.succeeded? - end - - if not a.nil? and a.active? and a.enabled - if a.authenticated?(password) or (Masq::Engine.config.masq['trust_basic_auth'] and basic_auth_used) - a.last_authenticated_at, a.last_authenticated_by_yubikey = Time.now, a.authenticated_with_yubikey? - a.save(:validate => false) - return a - end - end - end - - # Encrypts some data with the salt. - def self.encrypt(password, salt) - Digest::SHA1.hexdigest("--#{salt}--#{password}--") - end - # Encrypts the password with the user salt def encrypt(password) self.class.encrypt(password, salt) @@ -114,19 +141,19 @@ def encrypt(password) def authenticated?(password) if password.nil? - return false + false elsif password.length < 50 && !(yubico_identity? && yubikey_mandatory?) encrypt(password) == crypted_password - elsif Masq::Engine.config.masq['can_use_yubikey'] - password, yubico_otp = Account.split_password_and_yubico_otp(password) - encrypt(password) == crypted_password && @authenticated_with_yubikey = yubikey_authenticated?(yubico_otp) + elsif Masq::Engine.config.masq["can_use_yubikey"] + password, yubico_otp = self.class.split_password_and_yubico_otp(password) + @authenticated_with_yubikey = yubikey_authenticated?(yubico_otp) if encrypt(password) == crypted_password end end # Is the Yubico OTP valid and belongs to this account? def yubikey_authenticated?(otp) - if yubico_identity? && Account.verify_yubico_otp(otp) - (Account.extract_yubico_identity_from_otp(otp) == yubico_identity) + if yubico_identity? && self.class.verify_yubico_otp(otp) + (self.class.extract_yubico_identity_from_otp(otp) == yubico_identity) else false end @@ -137,9 +164,9 @@ def authenticated_with_yubikey? end def associate_with_yubikey(otp) - if Account.verify_yubico_otp(otp) - self.yubico_identity = Account.extract_yubico_identity_from_otp(otp) - save(:validate => false) + if self.class.verify_yubico_otp(otp) + self.yubico_identity = self.class.extract_yubico_identity_from_otp(otp) + save(validate: false) else false end @@ -151,23 +178,23 @@ def remember_token? # These create and unset the fields required for remembering users between browser closes def remember_me - remember_me_for 2.weeks + remember_me_for(2.weeks) end def remember_me_for(time) - remember_me_until time.from_now.utc + remember_me_until(time.from_now.utc) end def remember_me_until(time) self.remember_token_expires_at = time self.remember_token = encrypt("#{email}--#{remember_token_expires_at}") - save(:validate => false) + save(validate: false) end def forget_me self.remember_token_expires_at = nil self.remember_token = nil - save(:validate => false) + save(validate: false) end def forgot_password! @@ -199,7 +226,7 @@ def disable! def encrypt_password return if password.blank? - self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record? + self.salt = Digest::SHA1.hexdigest("--#{Time.now}--#{login}--") if new_record? self.crypted_password = encrypt(password) end @@ -208,38 +235,11 @@ def password_required? end def make_password_reset_code - self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join ) - end - - private - - # Returns the first twelve chars from the Yubico OTP, - # which are used to identify a Yubikey - def self.extract_yubico_identity_from_otp(yubico_otp) - yubico_otp[0..11] - end - - # Recieves a login token which consists of the users password and - # a Yubico one time password (the otp is always 44 characters long) - def self.split_password_and_yubico_otp(token) - token.reverse! - yubico_otp = token.slice!(0..43).reverse - password = token.reverse - [password, yubico_otp] - end - - # Utilizes the Yubico library to verify an one time password - def self.verify_yubico_otp(otp) - begin - Yubikey::OTP::Verify.new(otp).valid? - rescue Yubikey::OTP::InvalidOTPError - false - end + self.password_reset_code = Digest::SHA1.hexdigest(Time.now.to_s.split("").sort_by { rand }.join) end def deliver_forgot_password - AccountMailer.forgot_password(self).deliver_now if recently_forgot_password? + Masq::AccountMailer.forgot_password(self).deliver_now if recently_forgot_password? end - end end diff --git a/app/models/masq/open_id_request.rb b/app/models/masq/open_id_request.rb index ba39c85..f257514 100644 --- a/app/models/masq/open_id_request.rb +++ b/app/models/masq/open_id_request.rb @@ -2,39 +2,41 @@ module Masq class OpenIdRequest < ActiveRecord::Base validates_presence_of :token, :parameters - before_validation :make_token, :on => :create + before_validation :make_token, on: :create - #attr_accessible :parameters - serialize :parameters, JSON + if Rails.gem_version >= Gem::Version.create("6.1") + serialize :parameters, type: Hash, coder: JSON + else # Rails 5.2 & 6.0 + serialize :parameters, JSON + end def parameters self[:parameters] end + def parameters=(params) self[:parameters] = case params # arbitrary params passed as Hash when Hash - params.delete_if { |k,v| k.index('openid.') != 0 } + params.delete_if { |k, _v| k.index("openid.") != 0 } # params from ActionController (does not inherit directly from HashWithIndifferentAccess after Rails 4.2) when ActionController::Parameters - params.to_unsafe_h.delete_if { |k,v| k.index('openid.') != 0 } - else - nil + params.to_unsafe_h.delete_if { |k, _v| k.index("openid.") != 0 } end end def from_trusted_domain? - host = URI.parse(parameters['openid.realm'] || parameters['openid.trust_root']).host - unless Masq::Engine.config.masq['trusted_domains'].nil? - Masq::Engine.config.masq['trusted_domains'].find { |domain| host.ends_with? domain } - end + host = URI.parse(parameters["openid.realm"] || parameters["openid.trust_root"]).host + return false if Masq::Engine.config.masq["trusted_domains"].nil? + + Masq::Engine.config.masq["trusted_domains"].find { |domain| host.to_s.ends_with?(domain) } end - private + protected def make_token - self.token = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join ) + self.token = Digest::SHA1.hexdigest(Time.now.to_s.split("").sort_by { rand }.join) end end end diff --git a/app/models/masq/persona.rb b/app/models/masq/persona.rb index 07b87a8..01be3ac 100644 --- a/app/models/masq/persona.rb +++ b/app/models/masq/persona.rb @@ -1,38 +1,47 @@ module Masq class Persona < ActiveRecord::Base belongs_to :account - has_many :sites, :dependent => :destroy + has_many :sites, dependent: :destroy validates_presence_of :account validates_presence_of :title - validates_uniqueness_of :title, :scope => :account_id + validates_uniqueness_of :title, scope: :account_id before_destroy :check_deletable! - #attr_protected :account_id, :deletable + # attr_protected :account_id, :deletable - def self.properties - Persona.mappings.keys - end + class << self + def properties + mappings.keys + end + + def attribute_name_for_type_uri(type_uri) + prop = mappings.detect { |i| i[1].include?(type_uri) } + prop ? prop[0] : nil + end - def self.attribute_name_for_type_uri(type_uri) - prop = mappings.detect { |i| i[1].include?(type_uri) } - prop ? prop[0] : nil + # Mappings for SReg names and AX Type URIs to attributes + def mappings + Masq::Engine.config.masq["attribute_mappings"] + end end + public + # Returns the personas attribute for the given SReg name or AX Type URI def property(type) prop = Persona.mappings.detect { |i| i[1].include?(type) } - prop ? self.send(prop[0]).to_s : nil + prop ? send(prop[0]).to_s : nil end def date_of_birth - "#{dob_year? ? dob_year : '0000'}-#{dob_month? ? dob_month.to_s.rjust(2, '0') : '00'}-#{dob_day? ? dob_day.to_s.rjust(2, '0') : '00'}" + "#{dob_year? ? dob_year : "0000"}-#{dob_month? ? dob_month.to_s.rjust(2, "0") : "00"}-#{dob_day? ? dob_day.to_s.rjust(2, "0") : "00"}" end def fullname=(name) - self.firstname, self.surname = name.to_s.split(' ') - self.surname ||= self.firstname + self.firstname, self.surname = name.to_s.split(" ") + self.surname ||= firstname self[:fullname] = name end @@ -41,7 +50,6 @@ def date_of_birth=(dob) self.dob_year = res[0] self.dob_month = res[1] self.dob_day = res[2] - dob end protected @@ -49,12 +57,5 @@ def date_of_birth=(dob) def check_deletable! raise ActiveRecord::RecordNotDestroyed unless deletable end - - private - - # Mappings for SReg names and AX Type URIs to attributes - def self.mappings - Masq::Engine.config.masq['attribute_mappings'] - end end end diff --git a/app/models/masq/release_policy.rb b/app/models/masq/release_policy.rb index f6b758e..a228b2a 100644 --- a/app/models/masq/release_policy.rb +++ b/app/models/masq/release_policy.rb @@ -4,8 +4,8 @@ class ReleasePolicy < ActiveRecord::Base validates_presence_of :site validates_presence_of :property - validates_uniqueness_of :property, :scope => [:site_id, :type_identifier] + validates_uniqueness_of :property, scope: [:site_id, :type_identifier] - #attr_accessible :property, :type_identifier + # attr_accessible :property, :type_identifier end end diff --git a/app/models/masq/site.rb b/app/models/masq/site.rb index 103cb2a..64273fe 100644 --- a/app/models/masq/site.rb +++ b/app/models/masq/site.rb @@ -2,15 +2,15 @@ module Masq class Site < ActiveRecord::Base belongs_to :account belongs_to :persona - has_many :release_policies, :dependent => :destroy + has_many :release_policies, dependent: :destroy validates_presence_of :url, :persona, :account - validates_uniqueness_of :url, :scope => :account_id - #attr_accessible :url, :persona_id, :properties, :ax_fetch, :sreg + validates_uniqueness_of :url, scope: :account_id + # attr_accessible :url, :persona_id, :properties, :ax_fetch, :sreg # Sets the release policies by first deleting the old ones and # then appending a new one for every given sreg and ax property. - # This setter is used to set the attributes recieved from the + # This setter is used to set the attributes received from the # update site form, so it gets passed AX and SReg properties. # To be backwards compatible (SReg seems to be obsolete now that # there is AX), SReg properties get a type_identifier matching @@ -19,7 +19,7 @@ class Site < ActiveRecord::Base def properties=(props) release_policies.destroy_all props.each_pair do |property, details| - release_policies.build(:property => property, :type_identifier => details['type']) if details['value'] + release_policies.build(property: property, type_identifier: details["type"]) if details["value"] end end @@ -28,7 +28,7 @@ def properties=(props) # to set the attributes recieved from the decision form. def ax_fetch=(props) props.each_pair do |property, details| - release_policies.build(:property => property, :type_identifier => details['type']) if details['value'] + release_policies.build(property: property, type_identifier: details["type"]) if details["value"] end end @@ -37,7 +37,7 @@ def ax_fetch=(props) # to set the attributes recieved from the decision form. def sreg=(props) props.each_key do |property| - release_policies.build(:property => property, :type_identifier => property) + release_policies.build(property: property, type_identifier: property) end end @@ -53,13 +53,13 @@ def sreg_properties end # Returns a hash with all released AX properties. - # AX properties have an URL as type_identifier. + # AX properties have a URL as type_identifier. def ax_properties props = {} release_policies.each do |rp| - if rp.type_identifier.match("://") + if rp.type_identifier.match?("://") props["type.#{rp.property}"] = rp.type_identifier - props["value.#{rp.property}"] = persona.property(rp.type_identifier ) + props["value.#{rp.property}"] = persona.property(rp.type_identifier) end end props diff --git a/app/views/layouts/masq/consumer.html.erb b/app/views/layouts/masq/consumer.html.erb index 69ad620..ce08590 100644 --- a/app/views/layouts/masq/consumer.html.erb +++ b/app/views/layouts/masq/consumer.html.erb @@ -21,7 +21,7 @@