diff --git a/Gemfile b/Gemfile index 2e4c4122e..72d743af0 100644 --- a/Gemfile +++ b/Gemfile @@ -279,6 +279,9 @@ group :test do gem "database_cleaner" , "1.5.1" gem "selenium-webdriver", "2.47.1" + gem "cucumber-api-steps", "0.13", require: false + gem "json_spec", "1.1.4" + # General helpers gem "factory_girl_rails", "4.5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 7d873fb3b..ad32b01d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -57,6 +57,7 @@ GEM ast (2.2.0) astrolabe (1.3.1) parser (~> 2.2) + attr_required (1.0.0) autoprefixer-rails (6.2.2) execjs json @@ -66,6 +67,7 @@ GEM jquery-rails railties bcrypt (3.1.10) + bindata (2.1.0) bootstrap-sass (3.3.6) autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) @@ -126,6 +128,10 @@ GEM gherkin (~> 2.12) multi_json (>= 1.7.5, < 2.0) multi_test (>= 0.1.2) + cucumber-api-steps (0.13) + cucumber (>= 1.2.1) + jsonpath (>= 0.1.2) + rspec (>= 2.12.0) cucumber-rails (1.4.2) capybara (>= 1.1.2, < 3) cucumber (>= 1.3.8, < 2) @@ -390,6 +396,7 @@ GEM httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) + httpclient (2.7.1) i18n (0.7.0) i18n-inflector (2.6.7) i18n (>= 0.4.1) @@ -423,8 +430,19 @@ GEM multi_json (>= 1.3) rake json (1.8.3) + json-jwt (1.5.1) + activesupport + bindata + multi_json (>= 1.3) + securecompare + url_safe_base64 json-schema (2.5.2) addressable (~> 2.3.8) + json_spec (1.1.4) + multi_json (~> 1.0) + rspec (>= 2.0, < 4.0) + jsonpath (0.5.7) + multi_json jwt (1.5.2) kaminari (0.16.3) actionpack (>= 3.0.0) @@ -504,6 +522,17 @@ GEM open_graph_reader (0.6.1) faraday (~> 0.9.0) nokogiri (~> 1.6) + openid_connect (0.9.2) + activemodel + attr_required (>= 1.0.0) + json (>= 1.4.3) + json-jwt (>= 1.5.0) + rack-oauth2 (>= 1.2.1) + swd (>= 1.0.0) + tzinfo + validate_email + validate_url + webfinger (>= 1.0.1) orm_adapter (0.5.0) parser (2.2.3.0) ast (>= 1.1, < 3.0) @@ -545,6 +574,12 @@ GEM activesupport rack-mobile-detect (0.4.0) rack + rack-oauth2 (1.2.1) + activesupport (>= 2.3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + multi_json (>= 1.3.6) + rack (>= 1.1) rack-piwik (0.3.0) rack-pjax (0.8.0) nokogiri (~> 1.5) @@ -708,6 +743,7 @@ GEM scss_lint (0.42.2) rainbow (~> 2.0) sass (~> 3.4.15) + securecompare (1.0.0) selenium-webdriver (2.47.1) childprocess (~> 0.5) multi_json (~> 1.0) @@ -757,6 +793,12 @@ GEM activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) state_machine (1.2.0) + swd (1.0.0) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + i18n + json (>= 1.4.3) sysexits (1.2.0) systemu (2.6.5) terminal-table (1.5.2) @@ -797,11 +839,22 @@ GEM kgio (~> 2.6) rack raindrops (~> 0.7) + url_safe_base64 (0.2.2) uuid (2.3.8) macaddr (~> 1.0) valid (1.1.0) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.2) + activemodel (>= 3.0.0) + addressable warden (1.2.4) rack (>= 1.0) + webfinger (1.0.1) + activesupport + httpclient (>= 2.4) + multi_json webmock (1.22.3) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -830,6 +883,7 @@ DEPENDENCIES carrierwave (= 0.10.0) compass-rails (= 2.0.5) configurate (= 0.3.1) + cucumber-api-steps (= 0.13) cucumber-rails (= 1.4.2) database_cleaner (= 1.5.1) devise (= 3.5.3) @@ -867,6 +921,7 @@ DEPENDENCIES jshintrb (= 0.3.0) json (= 1.8.3) json-schema (= 2.5.2) + json_spec (= 1.1.4) leaflet-rails (= 0.7.4) logging-rails (= 0.5.0) markerb (= 1.1.0) @@ -882,6 +937,7 @@ DEPENDENCIES omniauth-twitter (= 1.2.1) omniauth-wordpress (= 0.2.2) open_graph_reader (= 0.6.1) + openid_connect pg (= 0.18.4) pronto (= 0.5.3) pronto-haml (= 0.5.0) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f929728ae..823f2ba3e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,9 +3,17 @@ # the COPYRIGHT file. class UsersController < ApplicationController - before_action :authenticate_user!, :except => [:new, :create, :public, :user_photo] + include Openid::Authentication + + before_action :authenticate_user!, except: [:new, :create, :public, :user_photo] + before_filter :require_access_token, only: [:show] respond_to :html + # TODO: Adjust so that it sends back only required elements, e.g, should not send hashed password (serialized_private_key) back + def show + render json: current_user + end + def edit @aspect = :user_edit @user = current_user diff --git a/app/models/token.rb b/app/models/token.rb index a6c50e814..37849715a 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -5,8 +5,10 @@ class Token < ActiveRecord::Base validates :token, presence: true, uniqueness: true + scope :valid, ->(time) { where("expires_at >= ?", time) } + def setup - self.token = SecureRandom.hex(32) + self.token = SecureRandom.hex(32) self.expires_at = 24.hours.from_now end @@ -16,4 +18,8 @@ class Token < ActiveRecord::Base expires_in: (expires_at - Time.now.utc).to_i ) end + + def accessible?(_scopes_or_claims_ = nil) + true # TODO: For now don't support scopes + end end diff --git a/config/application.rb b/config/application.rb index 6c650594b..acb8eba06 100644 --- a/config/application.rb +++ b/config/application.rb @@ -31,7 +31,7 @@ module Diaspora # Custom directories with classes and modules you want to be autoloadable. config.autoload_paths += %W{#{config.root}/app} config.autoload_once_paths += %W{#{config.root}/lib} - config.autoload_paths += %W{#{config.root}/lib/openid_connect} + config.autoload_paths += %W{#{config.root}/lib/openid} # Only load the plugins named here, in the order given (default is alphabetical). # :all can be used as a placeholder for all plugins not explicitly named. @@ -108,5 +108,9 @@ module Diaspora host: AppConfig.pod_uri.authority } config.action_mailer.asset_host = AppConfig.pod_uri.to_s + + config.middleware.use Rack::OAuth2::Server::Resource::Bearer, 'OpenID Connect' do |req| + Token.valid(Time.now.utc).find_by(token: req.access_token) || req.invalid_token! + end end end diff --git a/config/routes.rb b/config/routes.rb index 147212a98..8dfbfd08a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,7 +246,7 @@ Diaspora::Application.routes.draw do resources :authorizations, only: [:new, :create] match 'connect', to: 'connect#show', via: [:get, :post] match '.well-known/:id', to: 'discovery#show' , :via => [:get, :post] - match 'user_info', to: 'user#show', :via => [:get, :post] - post 'access_tokens', to: proc { |env| TokenEndpoint.new.call(env) } + post 'access_tokens', to: proc { |env| Openid::TokenEndpoint.new.call(env) } + match 'user_info', to: 'users#show', :via => [:get, :post] end end diff --git a/db/migrate/20150614134031_create_tokens.rb b/db/migrate/20150614134031_create_tokens.rb index 0d6a0cc03..613a7f53d 100644 --- a/db/migrate/20150614134031_create_tokens.rb +++ b/db/migrate/20150614134031_create_tokens.rb @@ -1,5 +1,5 @@ class CreateTokens < ActiveRecord::Migration - def change + def self.up create_table :tokens do |t| t.belongs_to :o_auth_application t.string :token diff --git a/features/desktop/protected_resource.feature b/features/desktop/protected_resource.feature new file mode 100644 index 000000000..4d0cee1ec --- /dev/null +++ b/features/desktop/protected_resource.feature @@ -0,0 +1,31 @@ +@javascript +# TODO: Add tests for expired access tokens +# TODO: Add tests to check for WWW-Authenticate response header field as according to RFC 6750 +Feature: Access protected resources using bearer access token + Background: + Given a user with username "bob" + And I log in manually as "bob" with password "password" + And I send a post request to the token endpoint using "bob"'s credentials + + Scenario: Valid bearer tokens sent via Authorization Request Header Field + # TODO: Add tests + + Scenario: Valid bearer tokens sent via Form Encoded Parameter + # TODO: Add tests + + Scenario: Valid bearer tokens sent via URI query parameter + When I use received valid bearer tokens to access user info via URI query parameter + Then I should receive "bob"'s id, username, and email + # TODO: I want to confirm that the cache-control header in the response is private as according to RFC 6750 + # Unfortunately, selenium doesn't allow access to response headers + + Scenario: Invalid bearer tokens sent via URI query parameter + When I use invalid bearer tokens to access user info via URI query parameter + Then I should receive an "invalid_token" error + + Scenario: Valid bearer tokens sent via URI query parameter but user is logged out + When I log out manually + And I use received valid bearer tokens to access user info via URI query parameter + Then I should see "Sign in" in the content + When I log in manually as "bob" with password "password" + Then I should receive "bob"'s id, username, and email diff --git a/features/step_definitions/openid_steps.rb b/features/step_definitions/openid_steps.rb new file mode 100644 index 000000000..180711c35 --- /dev/null +++ b/features/step_definitions/openid_steps.rb @@ -0,0 +1,37 @@ +# Password has been hard coded as all test accounts seem to have a password of "password" +Given /^I send a post request to the token endpoint using "([^\"]*)"'s credentials$/ do |username| + user = User.find_by(username: username) + tokenEndpointURL = "/openid/access_tokens" + tokenEndpointURLQuery = "?grant_type=password&username=" + + user.username + + "&password=password&client_id=4&client_secret=azerty" + post tokenEndpointURL + tokenEndpointURLQuery +end + +When /^I use received valid bearer tokens to access user info via URI query parameter$/ do + accessTokenJson = JSON.parse(last_response.body) + userInfoEndPointURL = "/openid/user_info/" + userInfoEndPointURLQuery = "?access_token=" + accessTokenJson["access_token"] + visit userInfoEndPointURL + userInfoEndPointURLQuery +end + +When /^I use invalid bearer tokens to access user info via URI query parameter$/ do + userInfoEndPointURL = "/openid/user_info/" + userInfoEndPointURLQuery = "?access_token=" + SecureRandom.hex(32) + visit userInfoEndPointURL + userInfoEndPointURLQuery +end + +Then /^I should receive "([^\"]*)"'s id, username, and email$/ do |username| + user = User.find_by_username(username) + expect(page).to have_content(user.username) + expect(page).to have_content(user.language) + expect(page).to have_content(user.email) +end + +Then /^I should receive an "([^\"]*)" error$/ do |error_message| + expect(page).to have_content(error_message) +end + +Then /^I should see "([^\"]*)" in the content$/ do |content| + expect(page).to have_content(content) +end diff --git a/features/support/env.rb b/features/support/env.rb index 888ef0191..07dc02d0d 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -12,6 +12,9 @@ require "capybara/cucumber" require "capybara/session" require "selenium/webdriver" +require "cucumber/api_steps" +require "json_spec/cucumber" + # Ensure we know the appservers port Capybara.server_port = AppConfig.pod_uri.port Rails.application.routes.default_url_options[:host] = AppConfig.pod_uri.host diff --git a/lib/openid_connect/LICENSE b/lib/openid/LICENSE similarity index 100% rename from lib/openid_connect/LICENSE rename to lib/openid/LICENSE diff --git a/lib/openid/authentication.rb b/lib/openid/authentication.rb new file mode 100644 index 000000000..7423ba21e --- /dev/null +++ b/lib/openid/authentication.rb @@ -0,0 +1,30 @@ +module Openid + module Authentication + + def self.included(klass) + klass.send :include, Authentication::Helper + end + + module Helper + def current_token + @current_token + end + end + + def require_access_token + @current_token = request.env[Rack::OAuth2::Server::Resource::ACCESS_TOKEN] + unless @current_token + raise Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new("Unauthorized user") + end + # TODO: This block is useless until we actually start checking for scopes + unless @current_token.try(:accessible?, required_scopes) + raise Rack::OAuth2::Server::Resource::Bearer::Forbidden.new(:insufficient_scope) + end + end + + # Scopes should be implemented here + def required_scopes + nil # as default + end + end +end diff --git a/lib/openid/authorization_endpoint.rb b/lib/openid/authorization_endpoint.rb new file mode 100644 index 000000000..3b8ea9a55 --- /dev/null +++ b/lib/openid/authorization_endpoint.rb @@ -0,0 +1,12 @@ +module Openid + class AuthorizationEndpoint + attr_accessor :app, :account, :client, :redirect_uri, :response_type, :scopes, :_request_, :request_uri, :request_object + delegate :call, to: :app + + def initialize(allow_approval = false, approved = false) + @app = Rack::OAuth2::Server::Authorize.new do |req, res| + req.unsupported_response_type! # TODO: not supported yet + end + end + end +end diff --git a/lib/openid/token_endpoint.rb b/lib/openid/token_endpoint.rb new file mode 100644 index 000000000..ae52a0305 --- /dev/null +++ b/lib/openid/token_endpoint.rb @@ -0,0 +1,35 @@ +module Openid + class TokenEndpoint + attr_accessor :app + delegate :call, to: :app + + def initialize + @app = Rack::OAuth2::Server::Token.new do |req, res| + case req.grant_type + when :password + o_auth_app = retrieveOrCreateNewClientApplication(req) + user = User.find_for_database_authentication(username: req.username) + if o_auth_app && user && user.valid_password?(req.password) + res.access_token = o_auth_app.tokens.create!.bearer_token + else + req.invalid_grant! + end + else + res.unsupported_grant_type! + end + end + end + + def retrieveOrCreateNewClientApplication(req) + retrieveClient(req) || createClient(req) + end + + def retrieveClient(req) + OAuthApplication.find_by_client_id req.client_id + end + + def createClient(req) + OAuthApplication.create!(client_id: req.client_id, client_secret: req.client_secret) + end + end +end diff --git a/lib/openid_connect/authorization_endpoint.rb b/lib/openid_connect/authorization_endpoint.rb deleted file mode 100644 index b584b2f28..000000000 --- a/lib/openid_connect/authorization_endpoint.rb +++ /dev/null @@ -1,10 +0,0 @@ -class AuthorizationEndpoint - attr_accessor :app, :account, :client, :redirect_uri, :response_type, :scopes, :_request_, :request_uri, :request_object - delegate :call, to: :app - - def initialize(allow_approval = false, approved = false) - @app = Rack::OAuth2::Server::Authorize.new do |req, res| - req.unsupported_response_type! # Not supported yet - end - end -end diff --git a/lib/openid_connect/token_endpoint.rb b/lib/openid_connect/token_endpoint.rb deleted file mode 100644 index d3f5d8d72..000000000 --- a/lib/openid_connect/token_endpoint.rb +++ /dev/null @@ -1,22 +0,0 @@ -class TokenEndpoint - attr_accessor :app - delegate :call, to: :app - - def initialize - @app = Rack::OAuth2::Server::Token.new do |req, res| - case req.grant_type - when :password - # If the grant type is password, the application does not have to be known - # If it does not exist, insert into DB - user = User.find_for_database_authentication(username: req.username) - o_auth_app = OAuthApplication.find_by_client_id req.client_id - o_auth_app ||= OAuthApplication.create!(client_id: req.client_id, client_secret: req.client_secret) - if user.valid_password? req.password - res.access_token = o_auth_app.tokens.create!.bearer_token - end - else - req.unsupported_grant_type! - end - end - end -end diff --git a/spec/integration/openid/token_endpoint_spec.rb b/spec/integration/openid/token_endpoint_spec.rb new file mode 100644 index 000000000..2d8598177 --- /dev/null +++ b/spec/integration/openid/token_endpoint_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe "Token Endpoint", type: :request do + describe "password grant type" do + context "when the username field is missing" do + it "should return an invalid request error" do + post "/openid/access_tokens?grant_type=password\&password=bluepin7\&client_id=4\&client_secret=azerty" + expect(response.body).to include("'username' required") + end + end + context "when the password field is missing" do + it "should return an invalid request error" do + post "/openid/access_tokens?grant_type=password\&username=bob\&client_id=4\&client_secret=azerty" + expect(response.body).to include("'password' required") + end + end + context "when the username does not match an existing user" do + it "should return an invalid request error" do + post "/openid/access_tokens?grant_type=password\&username=mewasdfrandom\&password=bluepin7\&client_id=4\&client_secret=azerty" + expect(response.body).to include("invalid_grant") + end + end + context "when the password is invalid" do + it "should return an invalid request error" do + post "/openid/access_tokens?grant_type=password\&username=mewasdfrandom\&password=bluepin7\&client_id=4\&client_secret=azerty" + expect(response.body).to include("invalid_grant") + end + end + context "when there are duplicate fields" do + it "should return an invalid request error" do + post "/openid/access_tokens?grant_type=password\&username=bob\&password=bluepin6\&username=bob\&password=bluepin7\&client_id=4\&client_secret=azerty" + expect(response.body).to include("invalid_grant") + # TODO: Apparently Nov's implementation lets this one pass; however, according to the OIDC spec, we are supposed to reject duplicate fields. Is this a security issue? + end + end + context "when the client is unauthorized" do + # TODO: If we support password grant, we should prevent access from unauthorized client applications + it "should return an error" do + fail + end + end + context "when many unauthorized requests are made" do + # TODO: If we support password grant, we should support a way to prevent brute force attacks (using rate-limitation or generating alerts) as specified by RFC 6749 4.3.2 Access Token Request + it "should generate an alert" do + fail + end + end + context "when the request is valid" do + it "should return an access token" do + post "/openid/access_tokens?grant_type=password\&username=bob\&password=bluepin7\&client_id=4\&client_secret=azerty" + json = JSON.parse(response.body) + expect(json["access_token"].length).to eq(64) + expect(json["token_type"]).to eq("bearer") + expect(json.keys).to include("expires_in") + end + end + end + describe "unsupported grant type" do + it "should return an unsupported grant type error" do + post "/openid/access_tokens?grant_type=me\&username=bob\&password=bluepin7\&client_id=4\&client_secret=azerty" + expect(response.body).to include "unsupported_grant_type" + end + end +end diff --git a/spec/lib/openid_connect/token_endpoint_spec.rb b/spec/lib/openid_connect/token_endpoint_spec.rb deleted file mode 100644 index 44e6a26b1..000000000 --- a/spec/lib/openid_connect/token_endpoint_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'rspec' - -describe TokenEndpoint do - - it "shoud generate a token" do - - end -end