From c7e4e1d60f30f9f32c60f68df3eb00d3a9b58eb7 Mon Sep 17 00:00:00 2001 From: Vova Date: Sun, 4 Feb 2018 13:16:12 +0200 Subject: [PATCH] Add AR mixins to simple oauth2 --- .codeclimate.yml | 25 ++++ .coveralls.yml | 1 + .gitignore | 50 +------ .hound.yml | 9 ++ .rubocop.yml | 6 + .rubocop_todo.yml | 12 ++ .travis.yml | 31 ++++ Gemfile | 17 +++ README.md | 137 +++++++++++++++++- Rakefile | 17 +++ activerecord_simple_oauth2.gemspec | 25 ++++ bin/console | 7 + config/database.yml | 19 +++ db/migrations/20180203210135_create_schema.rb | 47 ++++++ db/schema.rb | 67 +++++++++ lib/activerecord_simple_oauth2.rb | 6 + .../mixins/access_grant.rb | 81 +++++++++++ .../mixins/access_token.rb | 132 +++++++++++++++++ .../mixins/client.rb | 55 +++++++ .../mixins/resource_owner.rb | 26 ++++ lib/activerecord_simple_oauth2/version.rb | 29 ++++ spec/config/version_spec.rb | 11 ++ spec/mixins/access_grant_spec.rb | 74 ++++++++++ spec/mixins/access_token_spec.rb | 137 ++++++++++++++++++ spec/mixins/client_spec.rb | 77 ++++++++++ spec/mixins/resource_owner_spec.rb | 41 ++++++ spec/spec_helper.rb | 40 +++++ spec/support/mixins.rb | 17 +++ 28 files changed, 1149 insertions(+), 47 deletions(-) create mode 100644 .codeclimate.yml create mode 100644 .coveralls.yml create mode 100644 .hound.yml create mode 100644 .rubocop.yml create mode 100644 .rubocop_todo.yml create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 Rakefile create mode 100644 activerecord_simple_oauth2.gemspec create mode 100755 bin/console create mode 100644 config/database.yml create mode 100644 db/migrations/20180203210135_create_schema.rb create mode 100644 db/schema.rb create mode 100644 lib/activerecord_simple_oauth2.rb create mode 100644 lib/activerecord_simple_oauth2/mixins/access_grant.rb create mode 100644 lib/activerecord_simple_oauth2/mixins/access_token.rb create mode 100644 lib/activerecord_simple_oauth2/mixins/client.rb create mode 100644 lib/activerecord_simple_oauth2/mixins/resource_owner.rb create mode 100644 lib/activerecord_simple_oauth2/version.rb create mode 100644 spec/config/version_spec.rb create mode 100644 spec/mixins/access_grant_spec.rb create mode 100644 spec/mixins/access_token_spec.rb create mode 100644 spec/mixins/client_spec.rb create mode 100644 spec/mixins/resource_owner_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/mixins.rb diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..e3e3bd5 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,25 @@ +--- +engines: + duplication: + enabled: true + config: + languages: + - ruby + - javascript + - python + - php + fixme: + enabled: true + rubocop: + enabled: true +ratings: + paths: + - "**.inc" + - "**.js" + - "**.jsx" + - "**.module" + - "**.php" + - "**.py" + - "**.rb" +exclude_paths: +- spec/ diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..9160059 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +service_name: travis-ci diff --git a/.gitignore b/.gitignore index 5e1422c..8082097 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,10 @@ *.gem -*.rbc -/.config +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ /coverage/ -/InstalledFiles +/doc/ /pkg/ /spec/reports/ -/spec/examples.txt -/test/tmp/ -/test/version_tmp/ /tmp/ - -# Used by dotenv library to load environment variables. -# .env - -## Specific to RubyMotion: -.dat* -.repl_history -build/ -*.bridgesupport -build-iPhoneOS/ -build-iPhoneSimulator/ - -## Specific to RubyMotion (use of CocoaPods): -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# vendor/Pods/ - -## Documentation cache and generated files: -/.yardoc/ -/_yardoc/ -/doc/ -/rdoc/ - -## Environment normalization: -/.bundle/ -/vendor/bundle -/lib/bundler/man/ - -# for a library or gem, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# Gemfile.lock -# .ruby-version -# .ruby-gemset - -# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: -.rvmrc diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..85c8c35 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,9 @@ +ruby: + config_file: .rubocop_todo.yml + +fail_on_violations: true + +AllCops: + Exclude: + - spec/**/* + - db/**/* diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..958da6d --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,6 @@ +AllCops: + Exclude: + - spec/**/* + - db/**/* + +inherit_from: .rubocop_todo.yml diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..8fce54b --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,12 @@ +Style/FrozenStringLiteralComment: + Enabled: false +Style/StringLiterals: + EnforcedStyle: single_quotes +Metrics/MethodLength: + Max: 15 +Metrics/LineLength: + Max: 120 +Style/Lambda: + Enabled: false +Style/DotPosition: + Enabled: false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..71f515d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +language: ruby +cache: bundler +bundler_args: --without yard guard benchmarks +notifications: + email: false + +addons: + code_climate: + repo_token: a79dc140c09279df903bea582c17d13eecb4e8d80a4d92e1e14f177c74547cc6 + +services: + - postgresql + +matrix: + allow_failures: + - rvm: ruby-head + include: + - rvm: 2.2.6 + - rvm: 2.3.3 + - rvm: 2.5.0 + - rvm: ruby-head + +after_success: + - bundle exec codeclimate-test-reporter + +before_install: + # - mysql -e 'CREATE DATABASE simple_oauth2_test;' + - gem install bundler -v '~> 1.10' + - bundle install + - rake db:create + - rake db:migrate diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e9b0899 --- /dev/null +++ b/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +gemspec + +gem 'activerecord' +gem 'otr-activerecord' +gem 'pg', '0.21.0' +gem 'rubocop', '~> 0.49.0', require: false + +group :test do + gem 'codeclimate-test-reporter', '~> 1.0.0' + gem 'coveralls', require: false + gem 'database_cleaner' + gem 'ffaker' + gem 'rspec-rails', '~> 3.4' + gem 'simplecov', require: false +end diff --git a/README.md b/README.md index 310b4da..359fe5e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ -# activerecord_simple_oauth2 -ActiveRecord mixin for simple_oauth2. +# ActiveRecord SimpleOAuth2 + +## Installation + +Add this line to your application's *Gemfile:* + +```ruby +gem 'activerecord_simple_oauth2' +``` + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install activerecord_simple_oauth2 + +## Usage + +*OAuth2* workflow implies the existence of the next four roles: **Access Token**, **Access Grant**, **Application** and **Resource Owner**. The gem needs to know what classes work, so you need to create them, and also you need to **configure** [Simple::OAuth2](https://github.com/simple-oauth2/simple_oauth2). + +Your project must include 4 models - *AccessToken*, *AccessGrant*, *Client* and *User* **for example**. These models must contain a specific set of API (methods). So everything that you need, it just include each `mixin` to specific class. + +***AccessToken*** class: +```ruby + # app/models/access_token.rb + + class AccessToken + include ActiveRecord::Simple::OAuth2::AccessToken + end +``` + +***AccessGrant*** class: +```ruby + # app/models/access_grant.rb + + class AccessGrant + include ActiveRecord::Simple::OAuth2::AccessGrant + end +``` + +***Client*** class: +```ruby + # app/models/client.rb + + class Client + include ActiveRecord::Simple::OAuth2::Client + end +``` + +***User*** class: +```ruby + # app/models/user.rb + + class User + include ActiveRecord::Simple::OAuth2::ResourceOwner + end +``` + +Migration for the simplest use case of the gem looks as follows: +```ruby +ActiveRecord::Schema.define(version: 3) do + create_table :users do |t| + t.string :username, null: false, unique: true + t.string :encrypted_password, null: false + + t.timestamps null: false + end + + create_table :applications do |t| + t.string :name, null: false + t.string :redirect_uri, null: false + t.string :key, null: false, unique: true, index: true, default: ::Simple::OAuth2.config.token_generator.generate + t.string :secret, null: false, unique: true, index: true, default: ::Simple::OAuth2.config.token_generator.generate + + t.timestamps null: false + end + + create_table :access_tokens do |t| + t.integer :resource_owner_id, null: false, index: true + t.integer :client_id, null: false, index: true + + t.string :token, null: false, unique: true, index: true, default: ::Simple::OAuth2.config.token_generator.generate + t.string :refresh_token, unique: true, index: true, default: ::Simple::OAuth2.config.issue_refresh_token ? ::Simple::OAuth2.config.token_generator.generate : '' + t.string :scopes + + t.datetime :revoked_at + t.datetime :expires_at, null: false + + t.timestamps null: false + end + + create_table :access_grant do |t| + t.integer :resource_owner_id, null: false, index: true + t.integer :client_id, null: false, index: true + + t.string :token, null: false, unique: true, index: true, default: ::Simple::OAuth2.config.token_generator.generate + t.string :redirect_uri, null: false + t.string :scopes + + t.datetime :revoked_at + t.datetime :expires_at, null: false + + t.timestamps null: false + end +end +``` + +And that's it. +Also you can take a look at the [mixins](https://github.com/simple-oauth2/activerecord_simple_oauth2/tree/master/lib/activerecord_simple_oauth2/mixins) to understand what they are doing and what they are returning. + +## Bugs and Feedback + +Bug reports and feedback are welcome on GitHub at https://github.com/simple-oauth2/activerecord_simple_oauth2/issues. + +## Contributing + +1. Fork the project. +1. Create your feature branch (`git checkout -b my-new-feature`). +1. Implement your feature or bug fix. +1. Add documentation for your feature or bug fix. +1. Add tests for your feature or bug fix. +1. Run `rake` and `rubocop` to make sure all tests pass. +1. Commit your changes (`git commit -am 'Add new feature'`). +1. Push to the branch (`git push origin my-new-feature`). +1. Create new pull request. + +Thanks. + +## License + +The gem is available as open source under the terms of the [MIT License](https://github.com/simple-oauth2/activerecord_simple_oauth2/blob/master/LICENSE). + +Copyright (c) 2018 Volodimir Partytskyi (volodimir.partytskyi@gmail.com). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4d2baa4 --- /dev/null +++ b/Rakefile @@ -0,0 +1,17 @@ +require 'bundler/setup' +require 'rspec/core/rake_task' +require 'simple_oauth2' +load 'tasks/otr-activerecord.rake' + +OTR::ActiveRecord.configure_from_file! 'config/database.yml' +OTR::ActiveRecord.db_dir = 'db' +OTR::ActiveRecord.migrations_paths = ['db/migrations'] + +desc 'Default: run specs.' +task default: :spec + +RSpec::Core::RakeTask.new(:spec) do |config| + config.verbose = false +end + +Bundler::GemHelper.install_tasks diff --git a/activerecord_simple_oauth2.gemspec b/activerecord_simple_oauth2.gemspec new file mode 100644 index 0000000..5ab933e --- /dev/null +++ b/activerecord_simple_oauth2.gemspec @@ -0,0 +1,25 @@ +# coding: utf-8 + +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require 'activerecord_simple_oauth2/version' + +Gem::Specification.new do |s| + s.name = 'activerecord_simple_oauth2' + s.version = ActiveRecord::Simple::OAuth2.gem_version + s.date = '2017-01-17' + s.summary = 'Mixin for ActiveRecord ORM' + s.description = 'ActiveRecord mixin for SimpleOAuth2 authorization' + s.authors = ['Volodimir Partytskyi'] + s.email = 'volodimir.partytskyi@gmail.com' + s.homepage = 'https://github.com/simple-oauth2/activerecord_simple_oauth2' + s.license = 'MIT' + + s.require_paths = %w[lib] + s.files = Dir['LICENSE', 'lib/**/*'] + + s.required_ruby_version = '>= 2.2.2' + + s.add_runtime_dependency 'simple_oauth2', '0.1.0' +end diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..e8fcdc1 --- /dev/null +++ b/bin/console @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'activerecord_simple_oauth2' + +require 'irb' +IRB.start diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..8e0dced --- /dev/null +++ b/config/database.yml @@ -0,0 +1,19 @@ +development: + adapter: postgresql + host: localhost + port: 5432 + database: activerecord_simple_oauth2_development + username: + password: + pool: 5 + template: template0 + +test: + adapter: postgresql + host: localhost + port: 5432 + database: travis_ci_test + username: + password: + pool: 5 + template: template0 diff --git a/db/migrations/20180203210135_create_schema.rb b/db/migrations/20180203210135_create_schema.rb new file mode 100644 index 0000000..5197ea9 --- /dev/null +++ b/db/migrations/20180203210135_create_schema.rb @@ -0,0 +1,47 @@ +class CreateSchema < ActiveRecord::Migration[5.1] + def change + create_table :users do |t| + t.string :username, null: false, unique: true + t.string :encrypted_password, null: false + + t.timestamps null: false + end + + create_table :applications do |t| + t.string :name, null: false + t.string :redirect_uri, null: false + t.string :key, null: false, unique: true, index: true + t.string :secret, null: false, unique: true, index: true + + t.timestamps null: false + end + + create_table :access_tokens do |t| + t.integer :resource_owner_id, null: false, index: true + t.integer :client_id, null: false, index: true + + t.string :token, null: false, unique: true, index: true + t.string :refresh_token, unique: true, index: true + t.string :scopes + + t.datetime :revoked_at + t.datetime :expires_at, null: false + + t.timestamps null: false + end + + create_table :access_grant do |t| + t.integer :resource_owner_id, null: false, index: true + t.integer :client_id, null: false, index: true + + t.string :token, null: false, unique: true, index: true + t.string :redirect_uri, null: false + t.string :scopes + + t.datetime :revoked_at + t.datetime :expires_at, null: false + + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..191fa20 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,67 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 20180203210135) do + + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "access_grant", force: :cascade do |t| + t.integer "resource_owner_id", null: false + t.integer "client_id", null: false + t.string "token", null: false + t.string "redirect_uri", null: false + t.string "scopes" + t.datetime "revoked_at" + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_access_grant_on_client_id" + t.index ["resource_owner_id"], name: "index_access_grant_on_resource_owner_id" + t.index ["token"], name: "index_access_grant_on_token" + end + + create_table "access_tokens", force: :cascade do |t| + t.integer "resource_owner_id", null: false + t.integer "client_id", null: false + t.string "token", null: false + t.string "refresh_token" + t.string "scopes" + t.datetime "revoked_at" + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_access_tokens_on_client_id" + t.index ["refresh_token"], name: "index_access_tokens_on_refresh_token" + t.index ["resource_owner_id"], name: "index_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_access_tokens_on_token" + end + + create_table "applications", force: :cascade do |t| + t.string "name", null: false + t.string "redirect_uri", null: false + t.string "key", null: false + t.string "secret", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_applications_on_key" + t.index ["secret"], name: "index_applications_on_secret" + end + + create_table "users", force: :cascade do |t| + t.string "username", null: false + t.string "encrypted_password", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + +end diff --git a/lib/activerecord_simple_oauth2.rb b/lib/activerecord_simple_oauth2.rb new file mode 100644 index 0000000..8d0c464 --- /dev/null +++ b/lib/activerecord_simple_oauth2.rb @@ -0,0 +1,6 @@ +require 'simple_oauth2' + +require 'activerecord_simple_oauth2/mixins/access_token' +require 'activerecord_simple_oauth2/mixins/access_grant' +require 'activerecord_simple_oauth2/mixins/resource_owner' +require 'activerecord_simple_oauth2/mixins/client' diff --git a/lib/activerecord_simple_oauth2/mixins/access_grant.rb b/lib/activerecord_simple_oauth2/mixins/access_grant.rb new file mode 100644 index 0000000..3e02ede --- /dev/null +++ b/lib/activerecord_simple_oauth2/mixins/access_grant.rb @@ -0,0 +1,81 @@ +module ActiveRecord + module Simple + module OAuth2 + # AccessGrant role mixin for ActiveRecord. + # Includes all the required API, associations, validations and callbacks. + module AccessGrant + extend ActiveSupport::Concern + + included do + # Returns associated Client instance. + # + # @return [Object] Client instance. + # + belongs_to :client, class_name: ::Simple::OAuth2.config.client_class_name, foreign_key: :client_id + + # Returns associated ResourceOwner instance. + # + # @return [Object] ResourceOwner instance. + # + belongs_to :resource_owner, class_name: ::Simple::OAuth2.config.resource_owner_class_name, + foreign_key: :resource_owner_id + + # Required fields! + validates :client_id, :redirect_uri, :token, presence: true + validates :token, uniqueness: true + + # Generate token + before_validation :generate_token, on: :create + # Setup lifetime for `#code` value. + before_validation :setup_expiration, on: :create + + # Searches for AccessGrant record with the specific `#token` value. + # + # @param token [#to_s] token value (any object that responds to `#to_s`). + # + # @return [Object, nil] AccessGrant object or nil if there is no record with such `#token`. + # + def self.by_token(token) + where(token: token.to_s).first + end + + # Create a new AccessGrant object. + # + # @param client [Object] Client instance. + # @param resource_owner [Object] ResourceOwner instance. + # @param redirect_uri [String] Redirect URI callback. + # @param scopes [String] set of scopes. + # + # @return [Object] AccessGrant object. + # + def self.create_for(client, resource_owner, redirect_uri, scopes = nil) + create( + client_id: client.id, + resource_owner_id: resource_owner.id, + redirect_uri: redirect_uri, + scopes: scopes + ) + end + + private + + # Generate token + # + # @return token [String] string object. + # + def generate_token + self.token = Simple::OAuth2.config.token_generator.generate + end + + # Set lifetime for `#code` value during creating a new record. + # + # @return clock [Time] time object. + # + def setup_expiration + self.expires_at = Time.now.utc + ::Simple::OAuth2.config.authorization_code_lifetime if expires_at.nil? + end + end + end + end + end +end diff --git a/lib/activerecord_simple_oauth2/mixins/access_token.rb b/lib/activerecord_simple_oauth2/mixins/access_token.rb new file mode 100644 index 0000000..d59570b --- /dev/null +++ b/lib/activerecord_simple_oauth2/mixins/access_token.rb @@ -0,0 +1,132 @@ +module ActiveRecord + module Simple + module OAuth2 + # AccessToken role mixin for ActiveRecord. + # Includes all the required API, associations, validations and callbacks. + module AccessToken + extend ActiveSupport::Concern + + included do # rubocop:disable Metrics/BlockLength + # Returns associated Client instance. + # + # @return [Object] Client instance. + # + belongs_to :client, class_name: ::Simple::OAuth2.config.client_class_name, foreign_key: :client_id + + # Returns associated ResourceOwner instance. + # + # @return [Object] ResourceOwner instance. + # + belongs_to :resource_owner, class_name: ::Simple::OAuth2.config.resource_owner_class_name, + foreign_key: :resource_owner_id + + # Required field! + validates :token, presence: true, uniqueness: true + + # Generate tokens + before_validation :generate_tokens, on: :create + # Setup lifetime for `#token` value. + before_validation :setup_expiration, on: :create + + class << self + # Searches for AccessToken record with the specific `#token` value. + # + # @param token [#to_s] token value (any object that responds to `#to_s`). + # + # @return [Object, nil] AccessToken object or nil if there is no record with such `#token`. + # + def by_token(token) + where(token: token.to_s).first + end + + # Returns an instance of the AccessToken with specific `#refresh_token` value. + # + # @param refresh_token [#to_s] refresh token value (any object that responds to `#to_s`). + # + # @return [Object, nil] AccessToken object or nil if there is no record with such `#refresh_token`. + # + def by_refresh_token(refresh_token) + where(refresh_token: refresh_token.to_s).first + end + + # Create a new AccessToken object. + # + # @param client [Object] Client instance. + # @param resource_owner [Object] ResourceOwner instance. + # @param scopes [String] set of scopes. + # + # @return [Object] AccessToken object. + # + def create_for(client, resource_owner, scopes = nil) + create( + client_id: client.id, + resource_owner_id: resource_owner.id, + scopes: scopes + ) + end + end + + # Indicates whether the object is expired (`#expires_at` present and expiration time has come). + # + # @return [Boolean] true if object expired and false in other case. + # + def expired? + expires_at && Time.now.utc > expires_at + end + + # Indicates whether the object has been revoked. + # + # @return [Boolean] true if revoked, false in other case. + # + def revoked? + revoked_at && revoked_at <= Time.now.utc + end + + # Revokes the object (updates `:revoked_at` attribute setting its value to the specific time). + # + # @param revoked_at [Time] time object. + # + # @return [Object] AccessToken object or raise ActiveRecord::Error::DocumentInvalid. + # + def revoke!(revoked_at = Time.now) + update_column(:revoked_at, revoked_at.utc) + end + + # Exposes token object to Bearer token. + # + # @return [Hash] bearer token instance. + # + def to_bearer_token + { + access_token: token, + expires_in: expires_at && ::Simple::OAuth2.config.access_token_lifetime.to_i, + refresh_token: refresh_token, + scope: scopes + } + end + + private + + # Generate tokens + # + # @return token [String] string object. + # @return refresh_token [String] string object. + # + def generate_tokens + self.token = Simple::OAuth2.config.token_generator.generate if token.blank? + self.refresh_token = Simple::OAuth2::UniqueToken.generate if Simple::OAuth2.config.issue_refresh_token + end + + # Set lifetime for token value during creating a new record. + # + # @return clock [Time] time object. + # + def setup_expiration + expires_in = ::Simple::OAuth2.config.access_token_lifetime.to_i + self.expires_at = Time.now.utc + expires_in if expires_at.nil? && !expires_in.nil? + end + end + end + end + end +end diff --git a/lib/activerecord_simple_oauth2/mixins/client.rb b/lib/activerecord_simple_oauth2/mixins/client.rb new file mode 100644 index 0000000..8049751 --- /dev/null +++ b/lib/activerecord_simple_oauth2/mixins/client.rb @@ -0,0 +1,55 @@ +module ActiveRecord + module Simple + module OAuth2 + # Client role mixin for ActiveRecord. + # Includes all the required API, associations, validations and callbacks. + module Client + extend ActiveSupport::Concern + + included do + # Returns associated AccessToken array. + # + # @return [Array] AccessToken array. + # + has_many :access_tokens, class_name: ::Simple::OAuth2.config.access_token_class_name, + foreign_key: :client_id, + dependent: :destroy + + # Returns associated AccessGrant array. + # + # @return [Array] AccessGrant array. + # + has_many :access_grants, class_name: ::Simple::OAuth2.config.access_grant_class_name, foreign_key: :client_id + + # Required fields! + validates :key, :secret, presence: true, uniqueness: true + + # Generate tokens + before_validation :generate_tokens, on: :create + + # Searches for Client record with the specific `#key` value. + # + # @param key [#to_s] key value (any object that responds to `#to_s`). + # + # @return [Object, nil] Client object or nil if there is no record with such `#key`. + # + def self.by_key(key) + where(key: key.to_s).first + end + + private + + # Generate tokens + # + # @return token [String] string object. + # @return refresh_token [String] string object. + # + def generate_tokens + self.key = Simple::OAuth2::UniqueToken.generate if key.blank? + self.secret = Simple::OAuth2::UniqueToken.generate if secret.blank? + end + end + end + end + end +end diff --git a/lib/activerecord_simple_oauth2/mixins/resource_owner.rb b/lib/activerecord_simple_oauth2/mixins/resource_owner.rb new file mode 100644 index 0000000..99cd8c6 --- /dev/null +++ b/lib/activerecord_simple_oauth2/mixins/resource_owner.rb @@ -0,0 +1,26 @@ +module ActiveRecord + module Simple + module OAuth2 + # ResourceOwner role mixin for ActiveRecord. + # Includes all the required API, associations, validations and callbacks + module ResourceOwner + extend ActiveSupport::Concern + + included do + # Searches for ResourceOwner record with the specific params. + # + # @param _client [Object] Client instance. + # @param username [String, #to_s] username value (any object that responds to `#to_s`). + # @param password [String] password value. + # + # @return [Object, nil] ResourceOwner object or nil if there is no record with such params. + # + def self.oauth_authenticate(_client, username, password) + user = where(username: username.to_s).first + user if user && user.encrypted_password == password + end + end + end + end + end +end diff --git a/lib/activerecord_simple_oauth2/version.rb b/lib/activerecord_simple_oauth2/version.rb new file mode 100644 index 0000000..bb49381 --- /dev/null +++ b/lib/activerecord_simple_oauth2/version.rb @@ -0,0 +1,29 @@ +module ActiveRecord + module Simple + # Semantic versioning + module OAuth2 + # ActiveRecordSimpleOAuth2 version + # + # @return [Gem::Version] version of the gem + # + def self.gem_version + Gem::Version.new VERSION::STRING + end + + # ActiveRecordSimpleOAuth2 semantic versioning module. + # Contains detailed info about gem version + module VERSION + # Level changes for implementation level detail changes, such as small bug fixes + PATCH = 0 + # Level changes for any backwards compatible API changes, such as new functionality/features + MINOR = 0 + # Level changes for backwards incompatible API changes, + # such as changes that will break existing users code if they update + MAJOR = 0 + + # Full gem version string + STRING = [MAJOR, MINOR, PATCH].join('.') + end + end + end +end diff --git a/spec/config/version_spec.rb b/spec/config/version_spec.rb new file mode 100644 index 0000000..361b8e5 --- /dev/null +++ b/spec/config/version_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe 'ActiveRecord::Simlpe::OAuth2 Version' do + context 'has a version string' do + it { expect(ActiveRecord::Simple::OAuth2::VERSION::STRING).to be_present } + end + + context 'returns version as an instance of Gem::Version' do + it { expect(ActiveRecord::Simple::OAuth2.gem_version).to be_an_instance_of(Gem::Version) } + end +end diff --git a/spec/mixins/access_grant_spec.rb b/spec/mixins/access_grant_spec.rb new file mode 100644 index 0000000..8c6fa3a --- /dev/null +++ b/spec/mixins/access_grant_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe AccessGrant do + let(:user) { User.create(username: FFaker::Internet.user_name, encrypted_password: FFaker::Internet.password) } + let(:client) { Client.create(name: FFaker::Internet.domain_word, redirect_uri: 'http://localhost:3000/home') } + let(:redirect_uri) { client.redirect_uri } + let(:scopes) { 'read write' } + let(:access_grant) { described_class.create_for(client, user, redirect_uri, scopes) } + + context 'associations' do + context '#client' do + subject { access_grant.client } + + it { is_expected.to eq client } + it { is_expected.not_to be_nil } + end + + context '#resource_owner' do + subject { access_grant.resource_owner } + + it { is_expected.to eq user } + it { is_expected.not_to be_nil } + end + end + + context '.create_for' do + subject { -> { access_grant } } + + it { is_expected.to change(AccessGrant, :count).from(0).to(1) } + end + + context '.by_token' do + subject { described_class.by_token(token) } + + let(:token) { access_grant.token } + + it { is_expected.to eq access_grant } + it { is_expected.not_to be_nil } + + context 'when token is nil' do + let(:token) {} + + it { is_expected.not_to eq access_grant } + it { is_expected.to be_nil } + end + end + + context 'default value' do + context 'for #token' do + subject { access_grant.token } + + it { is_expected.to eq access_grant.token } + it { is_expected.not_to be_nil } + end + + context 'for #expires_at' do + subject { access_grant.expires_at } + + it { is_expected.not_to be_nil } + end + + context 'for #created_at' do + subject { access_grant.created_at } + + it { is_expected.not_to be_nil } + end + + context 'for #updated_at' do + subject { access_grant.updated_at } + + it { is_expected.not_to be_nil } + end + end +end diff --git a/spec/mixins/access_token_spec.rb b/spec/mixins/access_token_spec.rb new file mode 100644 index 0000000..35a5d96 --- /dev/null +++ b/spec/mixins/access_token_spec.rb @@ -0,0 +1,137 @@ +require 'spec_helper' + +describe AccessToken do + let(:user) { User.create(username: FFaker::Internet.user_name, encrypted_password: FFaker::Internet.password) } + let(:client) { Client.create(name: FFaker::Internet.domain_word, redirect_uri: 'http://localhost:3000/home') } + let(:scopes) { 'read write' } + let(:access_token) { described_class.create_for(client, user, scopes) } + + context 'associations' do + context '#client' do + subject { access_token.client } + + it { is_expected.to eq client } + it { is_expected.not_to be_nil } + end + + context '#resource_owner' do + subject { access_token.resource_owner } + + it { is_expected.to eq user } + it { is_expected.not_to be_nil } + end + end + + context '#expired?' do + subject { access_token.expired? } + + before { access_token.update(expires_at: Time.now - 604_800) } # - 7 days + + it { is_expected.to be_truthy } + end + + context '#revoked?' do + subject { access_token.revoked? } + + before { access_token.revoke! } + + it { is_expected.to be_truthy } + end + + context '#revoke!' do + subject { -> { access_token.revoke! } } + + it { is_expected.to change(access_token, :revoked_at).from(nil) } + end + + context '#to_bearer_token' do + subject { access_token.to_bearer_token } + + let(:to_bearer_token) do + { + access_token: access_token.token, + expires_in: 7200, + refresh_token: access_token.refresh_token, + scope: 'read write' + } + end + + it { is_expected.to eq to_bearer_token } + end + + context '.create_for' do + subject { -> { access_token } } + + it { is_expected.to change(AccessToken, :count).from(0).to(1) } + end + + context '.by_token' do + subject { described_class.by_token(token) } + + let(:token) { access_token.token } + + it { is_expected.to eq access_token } + it { is_expected.not_to be_nil } + + context 'when token is nil' do + let(:token) {} + + it { is_expected.not_to eq access_token } + it { is_expected.to be_nil } + end + end + + context '.by_refresh_token' do + subject { described_class.by_refresh_token(refresh_token) } + + before { allow(Simple::OAuth2.config).to receive(:issue_refresh_token).and_return(true) } + + let(:refresh_token) { access_token.refresh_token } + + it { is_expected.to eq access_token } + it { is_expected.not_to be_nil } + + context 'when refresh_token is nil' do + let(:refresh_token) {} + + it { is_expected.not_to eq access_token } + it { is_expected.to be_nil } + end + end + + context 'default value' do + context 'for #token' do + subject { access_token.token } + + it { is_expected.not_to be_nil } + end + + context 'for refresh_token' do + subject { access_token.refresh_token } + + context "if config 'issue_refresh_token' is true" do + before { allow(Simple::OAuth2.config).to receive(:issue_refresh_token).and_return(true) } + + it { is_expected.to be_present } + end + + context "if config 'issue_refresh_token' is true" do + before { allow(Simple::OAuth2.config).to receive(:issue_refresh_token).and_return(false) } + + it { is_expected.to be_blank } + end + end + + context 'for #created_at' do + subject { access_token.created_at } + + it { is_expected.not_to be_nil } + end + + context 'for #updated_at' do + subject { access_token.updated_at } + + it { is_expected.not_to be_nil } + end + end +end diff --git a/spec/mixins/client_spec.rb b/spec/mixins/client_spec.rb new file mode 100644 index 0000000..87bb5ab --- /dev/null +++ b/spec/mixins/client_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Client do + let(:client) do + described_class.create( + name: FFaker::Internet.domain_word, + redirect_uri: 'http://localhost:3000/home' + ) + end + let(:key) { client.key } + + context 'associations' do + let(:scopes) {} + let(:redirect_uri) { client.redirect_uri } + let(:user) { User.create(username: FFaker::Internet.user_name, encrypted_password: FFaker::Internet.password) } + + context '#access_tokens' do + subject { client.access_tokens } + + let!(:access_token) { AccessToken.create_for(client, user, scopes) } + + it { is_expected.to include(access_token) } + it { is_expected.not_to be_empty } + end + + context '#access_grants' do + subject { client.access_grants } + + let!(:access_grant) { AccessGrant.create_for(client, user, redirect_uri, scopes) } + + it { is_expected.to include(access_grant) } + it { is_expected.not_to be_empty } + end + end + + context 'default value' do + context 'for #key' do + subject { client.key } + + it { is_expected.not_to be_nil } + end + + context 'for #secret' do + subject { client.secret } + + it { is_expected.not_to be_nil } + end + + context 'for #created_at' do + subject { client.created_at } + + it { is_expected.not_to be_nil } + end + + context 'for #updated_at' do + subject { client.updated_at } + + it { is_expected.not_to be_nil } + end + end + + context '.by_key' do + subject { described_class.by_key(key) } + + context 'when param is present' do + it { is_expected.to eq client } + it { is_expected.not_to be_nil } + end + + context 'when param is blank' do + let(:key) {} + + it { is_expected.not_to eq client } + it { is_expected.to be_nil } + end + end +end diff --git a/spec/mixins/resource_owner_spec.rb b/spec/mixins/resource_owner_spec.rb new file mode 100644 index 0000000..897337f --- /dev/null +++ b/spec/mixins/resource_owner_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe User do + let(:user) { User.create(username: FFaker::Internet.user_name, encrypted_password: FFaker::Internet.password) } + let(:username) { user.username } + let(:password) { user.encrypted_password } + let(:_client) {} + + context '.oauth_authenticate' do + subject { described_class.oauth_authenticate(_client, username, password) } + + it { is_expected.to eq user } + it { is_expected.not_to be_nil } + + context 'when username is nil' do + let(:username) {} + + it { is_expected.to be_nil } + end + + context 'when password is nil' do + let(:password) {} + + it { is_expected.to be_nil } + end + end + + context 'default value' do + context 'for #created_at' do + subject { user.created_at } + + it { is_expected.not_to be_nil } + end + + context 'for #updated_at' do + subject { user.updated_at } + + it { is_expected.not_to be_nil } + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..939e8e4 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,40 @@ +if RUBY_VERSION >= '1.9' + require 'simplecov' + require 'coveralls' + + SimpleCov.formatters = [ + SimpleCov::Formatter::HTMLFormatter, + Coveralls::SimpleCov::Formatter + ] + + SimpleCov.start do + add_filter '/spec/' + minimum_coverage(90) + end +end + +require 'ffaker' +require 'active_record' +require 'database_cleaner' + +require 'support/mixins' + +RSpec.configure do |config| + # config.include Helper + + # config.filter_run_excluding skip_if: true + + config.order = :random + config.color = true + + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end + end +end diff --git a/spec/support/mixins.rb b/spec/support/mixins.rb new file mode 100644 index 0000000..0b033c6 --- /dev/null +++ b/spec/support/mixins.rb @@ -0,0 +1,17 @@ +require File.expand_path('../../../lib/activerecord_simple_oauth2', __FILE__) + +class Client + include ActiveRecord::Simple::OAuth2::Client +end + +class AccessToken + include ActiveRecord::Simple::OAuth2::AccessToken +end + +class AccessGrant + include ActiveRecord::Simple::OAuth2::AccessGrant +end + +class User + include ActiveRecord::Simple::OAuth2::ResourceOwner +end