Skip to content

Commit

Permalink
Add route constraint(s) (#228)
Browse files Browse the repository at this point in the history
Co-authored-by: Tom de Grunt <[email protected]>
  • Loading branch information
mikker and tdegrunt authored May 28, 2024
1 parent d1ac8a4 commit 18244a0
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 5 deletions.
1 change: 1 addition & 0 deletions .rubyfmtignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test/dummy/**/*
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Added

- Added `Passwordless::Constraint` and `Passwordless::NotConstraint` for routing constraints (#228)

### Fixed

- Fixed double loading of locale files (#221)

## 1.6.0

### Changed
Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,24 @@

Add authentication to your Rails app without all the icky-ness of passwords. _Magic link_ authentication, if you will. We call it _passwordless_.

---
- [Installation](#installation)
- [Upgrading](#upgrading)
- [Usage](#usage)
- [Getting the current user, restricting access, the usual](#getting-the-current-user-restricting-access-the-usual)
- [Providing your own templates](#providing-your-own-templates)
- [Registering new users](#registering-new-users)
- [URLs and links](#urls-and-links)
- [Route constraints](#route-constraints)
- [Configuration](#configuration)
- [Delivery method](#delivery-method)
- [Token generation](#token-generation)
- [Timeout and Expiry](#timeout-and-expiry)
- [Redirection after sign-in](#redirection-after-sign-in)
- [Looking up the user](#looking-up-the-user)
- [Test helpers](#test-helpers)
- [Security considerations](#security-considerations)
- [Alternatives](#alternatives)
- [License](#license)

## Installation

Expand Down Expand Up @@ -149,6 +166,35 @@ config.action_mailer.default_url_options = {host: "www.example.com"}
routes.default_url_options[:host] ||= "www.example.com"
```

### Route constraints

With [constraints](https://guides.rubyonrails.org/routing.html#request-based-constraints) you can restrict access to certain routes.
Passwordless provides `Passwordless::Constraint` and it's negative counterpart `Passwordless::NotConstraint` for this purpose.

To limit a route to only authenticated `User`s:

```ruby
constraints Passwordless::Constraint.new(User) do
# ...
end
```

The constraint takes a second `if:` argument, that expects a block and is passed the `authenticatable` record, (ie. `User`):

```ruby
constraints Passwordless::Constraint.new(User, if: -> (user) { user.email.include?("john") }) do
# ...
end
```

The negated version has the same API but with the opposite result, ie. ensuring authenticated user **don't** have access:

```ruby
constraints Passwordless::NotConstraint.new(User) do
get("/no-users-allowed", to: "secrets#index")
end
```

## Configuration

To customize Passwordless, create a file `config/initializers/passwordless.rb`.
Expand Down
1 change: 1 addition & 0 deletions lib/passwordless.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "active_support"
require "passwordless/config"
require "passwordless/context"
require "passwordless/constraint"
require "passwordless/errors"
require "passwordless/engine"
require "passwordless/token_digest"
Expand Down
40 changes: 40 additions & 0 deletions lib/passwordless/constraint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

require "passwordless/controller_helpers"

module Passwordless
# A class the constraint routes to authenticated records
class Constraint
include ControllerHelpers

attr_reader :authenticatable_type, :predicate, :session

# @param [Class] authenticatable_type Authenticatable class
# @option options [Proc] :if A lambda that takes an authenticatable and returns a boolean
def initialize(authenticatable_type, **options)
@authenticatable_type = authenticatable_type
# `if' is a keyword but so we do this instead of keyword arguments
@predicate = options.fetch(:if) { -> (_) { true } }
end

def matches?(request)
# used in authenticate_by_session
@session = request.session
authenticatable = authenticate_by_session(authenticatable_type)
!!(authenticatable && predicate.call(authenticatable))
end
end

# A class the constraint routes to NOT authenticated records
class ConstraintNot < Constraint
# @param [Class] authenticatable_type Authenticatable class
# @option options [Proc] :if A lambda that takes an authenticatable and returns a boolean
def initialize(authenticatable_type, **options)
super
end

def matches?(request)
!super
end
end
end
2 changes: 1 addition & 1 deletion lib/passwordless/controller_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def authenticate_by_session(authenticatable_class)
end

# Signs in session
# @param authenticatable [Passwordless::Session] Instance of {Passwordless::Session}
# @param passwordless_session [Passwordless::Session] Instance of {Passwordless::Session}
# to sign in
# @return [ActiveRecord::Base] the record that is passed in.
def sign_in(passwordless_session)
Expand Down
4 changes: 2 additions & 2 deletions test/controllers/passwordless/sessions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,12 @@ class << User
test("PATCH /:passwordless_for/sign_in/:id -> SUCCESS / callable success path with 1 arg") do
passwordless_session = create_pwless_session(token: "hi")

with_config(success_redirect_path: lambda { |user| "/#{user.id}" }) do
with_config(success_redirect_path: lambda { |user| "/users/#{user.id}" }) do
patch("/users/sign_in/#{passwordless_session.identifier}", params: {passwordless: {token: "hi"}})
end

follow_redirect!
assert_equal "/#{passwordless_session.authenticatable.id}", path
assert_equal "/users/#{passwordless_session.authenticatable.id}", path
end

test("PATCH /:passwordless_for/sign_in/:id -> ERROR") do
Expand Down
6 changes: 5 additions & 1 deletion test/dummy/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
config.cache_store = :null_store

# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = :internal
if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
config.action_dispatch.show_exceptions = :none
else
config.action_dispatch.show_exceptions = false
end

# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
Expand Down
18 changes: 18 additions & 0 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,22 @@
scope("/locale/:locale") do
passwordless_for(:users, as: :locale_user)
end

constraints(Passwordless::Constraint.new(User)) do
get("/constraint/only-user", to: "secrets#index")
end

is_john = -> (user) { user.email.include?("john") }

constraints(Passwordless::Constraint.new(User, if: is_john)) do
get("/constraint/only-john", to: "secrets#index")
end

constraints(Passwordless::ConstraintNot.new(User)) do
get("/constraint/not-user", to: "secrets#index")
end

constraints(Passwordless::ConstraintNot.new(User, if: is_john)) do
get("/constraint/not-john", to: "secrets#index")
end
end
2 changes: 2 additions & 0 deletions test/mailers/passwordless/mailer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Passwordless::MailerTest < ActionMailer::TestCase

assert_match %r{www.example.org/users/sign_in/#{session.identifier}/hello}, email.body.to_s
end

ensure
# Reload the mailer again, because the config is reset back to the default
# after the `with_config` block.
Expand All @@ -67,6 +68,7 @@ class Passwordless::MailerTest < ActionMailer::TestCase

assert_equal ApplicationMailer.default.fetch(:from), Passwordless::Mailer.default.fetch(:from)
end

ensure
reload_mailer!
end
Expand Down
58 changes: 58 additions & 0 deletions test/passwordless/constraint_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

require "test_helper"
require "passwordless/test_helpers"

module Passwordless
class ConstraintTest < ActionDispatch::IntegrationTest
fixtures :users

test("restricts to users") do
assert_raises(ActionController::RoutingError) do
get "/constraint/only-user"
end

alice = users(:alice)
passwordless_sign_in(alice)

get "/constraint/only-user"
assert_response :success
end

test("restricts with predicate") do
alice = users(:alice)
passwordless_sign_in(alice)

assert_raises(ActionController::RoutingError) do
get "/constraint/only-john"
end

john = users(:john)
passwordless_sign_in(john)

get "/constraint/only-john"
assert_response :success
end

test("negative version") do
get "/constraint/not-user"
# redirect because not signed in but route is still matched
assert_response :redirect
end

test("negative version with predicate") do
passwordless_sign_in(users(:alice))
assert_raises(ActionController::RoutingError) do
get "/constraint/not-user"
end

get "/constraint/not-john"
assert_response :success

passwordless_sign_in(users(:john))
assert_raises(ActionController::RoutingError) do
get "/constraint/not-john"
end
end
end
end

0 comments on commit 18244a0

Please sign in to comment.