diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb3b06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..aa549b1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: ruby +rvm: + - 2.4.2 +before_install: gem install bundler -v 1.15.4 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..55cbbea --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +# Specify your gem's dependencies in roda-token-auth.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e3fadeb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Ronaldo Raivil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b1ca5 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Roda Token Authentication + +Adds token token authentication to Roda. +Based on [Roda Basic Auth](https://github.com/badosu/roda-basic-auth) + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'roda-token-auth' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install roda-token-auth + +## Configuration + +Configure your Roda application to use this plugin: + +```ruby +plugin :token_auth +``` + +You can pass global options, in this context they'll be shared between all +`r.token_auth` calls. + +```ruby +plugin :token_auth, authenticator: proc {|token, secret| [token, secret] == %w(foo bar)}, +``` + +## Usage + +Call `r.token_auth` inside the routes you want to authenticate the user, it will halt +the request with 401 response code if the authenticator is false. + +An additional `WWW-Authenticate` header is sent as specified on [rfc7235](https://tools.ietf.org/html/rfc7235#section-4.1) and it's realm can be configured. + +You can specify the local authenticator with a block: + +```ruby +r.token_auth { |token, secret| [token, secret] == %w(foo bar) } +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then 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](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/raivil/roda-token-auth. + +## License + +The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c92b11e --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..c868570 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "roda/roda_plugins/token_auth" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/roda/roda_plugins/token_auth.rb b/lib/roda/roda_plugins/token_auth.rb new file mode 100644 index 0000000..51da273 --- /dev/null +++ b/lib/roda/roda_plugins/token_auth.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require "roda" +require "roda/roda_plugins/token_auth/version" + +module Roda::RodaPlugins + module TokenAuth + DEFAULTS = { + token_variable: "X-Token", + secret_variable: "X-Secret", + unauthorized_headers: proc do |_opts| + { "Content-Type" => "application/json", "Content-Length" => "0" } + end + }.freeze + + def self.configure(app, opts = {}) + plugin_opts = (app.opts[:token_auth] ||= DEFAULTS) + app.opts[:token_auth] = plugin_opts.merge(opts) + app.opts[:token_auth].freeze + end + + module RequestMethods + def token_auth(opts = {}, &authenticator) + auth_opts = roda_class.opts[:token_auth].merge(opts) + authenticator ||= auth_opts[:authenticator] + + raise "Must provide an authenticator block" if authenticator.nil? + auth_token = header_variable(auth_opts, :token_variable) + auth_secret = header_variable(auth_opts, :secret_variable) + return if authenticator.call(auth_token, auth_secret) + auth_opts[:unauthorized]&.call(self) + halt [401, auth_opts[:unauthorized_headers].call(auth_opts), []] + end + + def header_variable(auth_opts, variable_name) + env["HTTP_#{auth_opts[variable_name]}".tr("-", "_").upcase] + end + end + end + + register_plugin(:token_auth, TokenAuth) +end diff --git a/lib/roda/roda_plugins/token_auth/version.rb b/lib/roda/roda_plugins/token_auth/version.rb new file mode 100644 index 0000000..0f228d5 --- /dev/null +++ b/lib/roda/roda_plugins/token_auth/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Roda + module RodaPlugins + module TokenAuth + VERSION = "0.1.0".freeze + end + end +end diff --git a/roda-token-auth.gemspec b/roda-token-auth.gemspec new file mode 100644 index 0000000..625a442 --- /dev/null +++ b/roda-token-auth.gemspec @@ -0,0 +1,38 @@ + +lib = File.expand_path("../lib", __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "roda/roda_plugins/token_auth/version" + +Gem::Specification.new do |spec| + spec.name = "roda-token-auth" + spec.version = Roda::RodaPlugins::TokenAuth::VERSION + spec.authors = ["Ronaldo Raivil"] + spec.email = ["raivil@gmail.com"] + + spec.summary = %(Plugin that adds token authentication methods to Roda) + spec.description = %(Plugin that adds token authentication methods to Roda) + spec.homepage = "https://github.com/raivil/roda-token-auth" + spec.license = "MIT" + + # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' + # to allow pushing to a single host or delete this section to allow pushing to any host. + if spec.respond_to?(:metadata) + spec.metadata["allowed_push_host"] = "https://rubygems.org" + else + raise "RubyGems 2.0 or newer is required to protect against " \ + "public gem pushes." + end + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "roda", ">= 2.0", "< 4.0" + spec.add_development_dependency "bundler", "~> 1.15" + spec.add_development_dependency "rake", "~> 10.0" + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "rack-test", "0.7.0" +end diff --git a/spec/roda/roda_plugins/token_auth_spec.rb b/spec/roda/roda_plugins/token_auth_spec.rb new file mode 100644 index 0000000..dcbc150 --- /dev/null +++ b/spec/roda/roda_plugins/token_auth_spec.rb @@ -0,0 +1,109 @@ +require File.expand_path("../../spec_helper", File.dirname(__FILE__)) + +RSpec.describe Roda::RodaPlugins::TokenAuth do + include Rack::Test::Methods + + attr_accessor :app + def roda + app = Class.new(Roda) + yield app + self.app = app + end + + def app_root(*opts, &block) + app.route do |r| + r.root do + r.token_auth(*opts, &block) + "I am GROOT!" + end + end + end + + it "has a version number" do + expect(Roda::RodaPlugins::TokenAuth::VERSION).not_to be nil + end + + describe "local authenticator" do + before do + roda do |r| + r.plugin :token_auth, authenticator: ->(token, secret) { [token, secret] == %w[foo bar] } + end + app_root { |u, p| [u, p] == %w[baz inga] } + end + context "correct credentials" do + it "returns correct body" do + header "X-Token", "baz" + header "X-Secret", "inga" + get "/" + expect(last_response.body).to eq("I am GROOT!") + end + it "is successful" do + header "X-Token", "baz" + header "X-Secret", "inga" + get "/" + expect(last_response.status).to eq(200) + end + end + context "invalid credentials" do + it "returns 401" do + header "X-Token", "foo" + header "X-Secret", "bar" + get "/" + expect(last_response.status).to eq(401) + end + context "no credentials" do + it "returns 401" do + get "/" + expect(last_response.status).to eq(401) + end + end + end + end + + describe "global authenticator" do + before do + roda { |r| r.plugin :token_auth, authenticator: ->(token, secret) { [token, secret] == %w[foo bar] } } + app_root + end + context "correct credentials" do + it "returns correct body" do + header "X-Token", "foo" + header "X-Secret", "bar" + get "/" + expect(last_response.body).to eq("I am GROOT!") + end + it "is successful" do + header "X-Token", "foo" + header "X-Secret", "bar" + get "/" + expect(last_response.status).to eq(200) + end + end + context "invalid credentials" do + it "returns 401" do + header "X-Token", "baz" + header "X-Secret", "inga" + get "/" + expect(last_response.status).to eq(401) + end + context "no credentials" do + it "returns 401" do + get "/" + expect(last_response.status).to eq(401) + end + end + end + end + + describe "invalid configs" do + before do + roda { |r| r.plugin :token_auth } + app_root + end + context "no authenticator" do + it "raises config error" do + expect { get "/" }.to raise_error(RuntimeError, /Must provide an authenticator block/) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..a2ac581 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,45 @@ +require "bundler/setup" +require "roda/roda_plugins/token_auth" + +require "rack/test" +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end + +# +# def app_root(*opts, &block) +# app.route do |r| +# r.root do +# r.basic_auth(*opts, &block) +# +# "I am ROOT!" +# end +# end +# end +# +# def roda +# app = Class.new(Roda) +# +# yield app +# +# self.app = app +# end +# +# def assert_authorized +# assert_equal 200, last_response.status +# assert_equal "I am ROOT!", last_response.body +# end +# +# def assert_unauthorized(realm: app.opts[:basic_auth][:realm]) +# assert_equal 401, last_response.status +# assert_equal "Basic realm=\"#{realm}\"", last_response['WWW-Authenticate'] +# end +# end