diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 8a5b0e20a86b4159777737b1a3851865e21f497d..8b1976bd92576fa8b8abd90fa6877ce4e293d2c9 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -262,3 +262,13 @@ table.u2f-registrations { border-right: solid 1px transparent; } } + +.oauth-application-show { + .scope-name { + font-weight: 600; + } + + .scopes-list { + padding-left: 18px; + } +} \ No newline at end of file diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 471d24934a017a5e802018a4eca7c759bd80e0f8..62f62e99a97cfc1d668afbfeeb8d3e12cd682d33 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -1,5 +1,8 @@ class Admin::ApplicationsController < Admin::ApplicationController + include OauthApplications + before_action :set_application, only: [:show, :edit, :update, :destroy] + before_action :load_scopes, only: [:new, :edit] def index @applications = Doorkeeper::Application.where("owner_id IS NULL") @@ -47,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController # Only allow a trusted parameter "white list" through. def application_params - params[:doorkeeper_application].permit(:name, :redirect_uri) + params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes) end end diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb new file mode 100644 index 0000000000000000000000000000000000000000..7210ed3eb32306c6c2422c4531cc1579b7c1b95b --- /dev/null +++ b/app/controllers/concerns/oauth_applications.rb @@ -0,0 +1,19 @@ +module OauthApplications + extend ActiveSupport::Concern + + included do + before_action :prepare_scopes, only: [:create, :update] + end + + def prepare_scopes + scopes = params.dig(:doorkeeper_application, :scopes) + + if scopes + params[:doorkeeper_application][:scopes] = scopes.join(' ') + end + end + + def load_scopes + @scopes = Doorkeeper.configuration.scopes + end +end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 0f54dfa4efc898ada20bfb20dd0c3d1819692c05..2ae4785b12cc1b43c1336240d0b5948bb75f4e96 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -2,10 +2,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include Gitlab::CurrentSettings include Gitlab::GonHelper include PageLayoutHelper + include OauthApplications before_action :verify_user_oauth_applications_enabled before_action :authenticate_user! before_action :add_gon_variables + before_action :load_scopes, only: [:index, :create, :edit] layout 'profile' diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 508b82a9a6c2c582fc9f781e3cc09aa088c89d38..6e007f17913350b0f92955218ffc2f93a4f2c25b 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,8 +1,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController - before_action :load_personal_access_tokens, only: :index - def index - @personal_access_token = current_user.personal_access_tokens.build + set_index_vars end def create @@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController flash[:personal_access_token] = @personal_access_token.token redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." else - load_personal_access_tokens + set_index_vars render :index end end @@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController private def personal_access_token_params - params.require(:personal_access_token).permit(:name, :expires_at) + params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) end - def load_personal_access_tokens + def set_index_vars + @personal_access_token ||= current_user.personal_access_tokens.build + @scopes = Gitlab::Auth::SCOPES @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index c4b095e0c0471c82e03e5cf97cac05d4fdaa1e40..10a34c42fd8e828c5fb8c9c07d40e889b2c95c20 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base include TokenAuthenticatable add_authentication_token_field :token + serialize :scopes, Array + belongs_to :user scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..ddaaed90e5befeec1f27f45c155bc8c3ca583a10 --- /dev/null +++ b/app/services/access_token_validation_service.rb @@ -0,0 +1,32 @@ +AccessTokenValidationService = Struct.new(:token) do + # Results: + VALID = :valid + EXPIRED = :expired + REVOKED = :revoked + INSUFFICIENT_SCOPE = :insufficient_scope + + def validate(scopes: []) + if token.expired? + return EXPIRED + + elsif token.revoked? + return REVOKED + + elsif !self.include_any_scope?(scopes) + return INSUFFICIENT_SCOPE + + else + return VALID + end + end + + # True if the token's scope contains any of the passed scopes. + def include_any_scope?(scopes) + if scopes.blank? + true + else + # Check whether the token is allowed access to any of the required scopes. + Set.new(scopes).intersection(Set.new(token.scopes)).present? + end + end +end diff --git a/app/services/oauth2/access_token_validation_service.rb b/app/services/oauth2/access_token_validation_service.rb deleted file mode 100644 index 264fdccde8fdf2e286fabf274ccdd8e339daa9cb..0000000000000000000000000000000000000000 --- a/app/services/oauth2/access_token_validation_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Oauth2::AccessTokenValidationService - # Results: - VALID = :valid - EXPIRED = :expired - REVOKED = :revoked - INSUFFICIENT_SCOPE = :insufficient_scope - - class << self - def validate(token, scopes: []) - if token.expired? - return EXPIRED - - elsif token.revoked? - return REVOKED - - elsif !self.sufficient_scope?(token, scopes) - return INSUFFICIENT_SCOPE - - else - return VALID - end - end - - protected - - # True if the token's scope is a superset of required scopes, - # or the required scopes is empty. - def sufficient_scope?(token, scopes) - if scopes.blank? - # if no any scopes required, the scopes of token is sufficient. - return true - else - # If there are scopes required, then check whether - # the set of authorized scopes is a superset of the set of required scopes - required_scopes = Set.new(scopes) - authorized_scopes = Set.new(token.scopes) - - return authorized_scopes >= required_scopes - end - end - end -end diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 4aacbb8cd7791c01485068310af6aa145e8c8e28..c689b26d6e6f02409c9c2db3b78af54bd46a2b56 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -18,6 +18,12 @@ Use %code= Doorkeeper.configuration.native_redirect_uri for local tests + + .form-group + = f.label :scopes, class: 'col-sm-2 control-label' + .col-sm-10 + = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes + .form-actions = f.submit 'Submit', class: "btn btn-save wide" = link_to "Cancel", admin_applications_path, class: "btn btn-default" diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 3eb9d61972b314a3fea5e12e7f460b69fbadf933..14683cc66e923d3a065611662097d300b01ffc73 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -2,8 +2,7 @@ %h3.page-title Application: #{@application.name} - -.table-holder +.table-holder.oauth-application-show %table.table %tr %td @@ -23,6 +22,9 @@ - @application.redirect_uri.split.each do |uri| %div %span.monospace= uri + + = render "shared/tokens/scopes_list", token: @application + .form-actions = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide pull-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 5c98265727a13e899f315e14d7705efd33e86b7e..b3313c7c985683c58c723303058ca3e4db3bba53 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -17,5 +17,9 @@ %code= Doorkeeper.configuration.native_redirect_uri for local tests + .form-group + = f.label :scopes, class: 'label-light' + = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes + .prepend-top-default = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 47442b78d48e4972b4f87e59070e83502ac4a7ea..559de63d96dd3c153fe61aa0becf7de33d1411ff 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -2,7 +2,7 @@ %h3.page-title Application: #{@application.name} -.table-holder +.table-holder.oauth-application-show %table.table %tr %td @@ -22,6 +22,9 @@ - @application.redirect_uri.split.each do |uri| %div %span.monospace= uri + + = render "shared/tokens/scopes_list", token: @application + .form-actions = link_to 'Edit', edit_oauth_application_path(@application), class: 'btn btn-primary wide pull-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' diff --git a/app/views/profiles/personal_access_tokens/_form.html.haml b/app/views/profiles/personal_access_tokens/_form.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..3f6efa339537f74bb466e1c343b721aa8c86a345 --- /dev/null +++ b/app/views/profiles/personal_access_tokens/_form.html.haml @@ -0,0 +1,21 @@ +- personal_access_token = local_assigns.fetch(:personal_access_token) +- scopes = local_assigns.fetch(:scopes) + += form_for [:profile, personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + + = form_errors(personal_access_token) + + .form-group + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true + + .form-group + = f.label :expires_at, class: 'label-light' + = f.text_field :expires_at, class: "datepicker form-control" + + .form-group + = f.label :scopes, class: 'label-light' + = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: personal_access_token, scopes: scopes + + .prepend-top-default + = f.submit 'Create Personal Access Token', class: "btn btn-create" diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 05a2ea67aa2189c8327fe45aa409c21f70ced497..bb4effeeeb14280b9114be5928726c4799cdddf8 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -28,21 +28,8 @@ Add a Personal Access Token %p.profile-settings-content Pick a name for the application, and we'll give you a unique token. - = form_for [:profile, @personal_access_token], - method: :post, html: { class: 'js-requires-input' } do |f| - = form_errors(@personal_access_token) - - .form-group - = f.label :name, class: 'label-light' - = f.text_field :name, class: "form-control", required: true - - .form-group - = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control", required: false - - .prepend-top-default - = f.submit 'Create Personal Access Token', class: "btn btn-create" + = render "form", personal_access_token: @personal_access_token, scopes: @scopes %hr @@ -56,6 +43,7 @@ %th Name %th Created %th Expires + %th Scopes %th %tbody - @active_personal_access_tokens.each do |token| @@ -67,6 +55,7 @@ = token.expires_at.to_date.to_s(:medium) - else %span.personal-access-tokens-never-expires-label Never + %td= token.scopes.present? ? token.scopes.join(", ") : "" %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this token? This action cannot be undone." } - else diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5074afb63a1245bdd4a18fc48a80cf0542f09a3b --- /dev/null +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -0,0 +1,9 @@ +- scopes = local_assigns.fetch(:scopes) +- prefix = local_assigns.fetch(:prefix) +- token = local_assigns.fetch(:token) + +- scopes.each do |scope| + %fieldset + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" + = label_tag "#{prefix}_scopes_#{scope}", scope + %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f99e905e95c7b261ca7adbcb4b7a93cf6b016cca --- /dev/null +++ b/app/views/shared/tokens/_scopes_list.html.haml @@ -0,0 +1,13 @@ +- token = local_assigns.fetch(:token) + +- return unless token.scopes.present? + +%tr + %td + Scopes + %td + %ul.scopes-list.append-bottom-0 + - token.scopes.each do |scope| + %li + %span.scope-name= scope + = "(#{t(scope, scope: [:doorkeeper, :scopes])})" diff --git a/changelogs/unreleased/20492-access-token-scopes.yml b/changelogs/unreleased/20492-access-token-scopes.yml new file mode 100644 index 0000000000000000000000000000000000000000..a9424ded6623c549a570f652d2490b2cce7a2826 --- /dev/null +++ b/changelogs/unreleased/20492-access-token-scopes.yml @@ -0,0 +1,4 @@ +--- +title: Add scopes for personal access tokens and OAuth tokens +merge_request: 5951 +author: diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index fc4b0a72addf4190b96ce3e38a2dfc143dcff239..88cd0f5f6524dffc586a6e04ac5ad2907e7f6463 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -52,8 +52,8 @@ Doorkeeper.configure do # Define access token scopes for your provider # For more information go to # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes - default_scopes :api - # optional_scopes :write, :update + default_scopes(*Gitlab::Auth::DEFAULT_SCOPES) + optional_scopes(*Gitlab::Auth::OPTIONAL_SCOPES) # Change the way client credentials are retrieved from the request object. # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index a4032a2142092d837a4fb42dac67a66d081891ec..1d728282d90fcbf17d20ca88870a6a6903a55d99 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -59,6 +59,7 @@ en: unknown: "The access token is invalid" scopes: api: Access your API + read_user: Read user information flash: applications: diff --git a/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb new file mode 100644 index 0000000000000000000000000000000000000000..91479de840b779f24c73cbaa9c67334c8458daba --- /dev/null +++ b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb @@ -0,0 +1,19 @@ +# The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. +# It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to +# `[]`. + +class AddColumnScopesToPersonalAccessTokens < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :personal_access_tokens, :scopes, :string, default: ['api'].to_yaml + end + + def down + remove_column :personal_access_tokens, :scopes + end +end diff --git a/db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb b/db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb new file mode 100644 index 0000000000000000000000000000000000000000..7df561d82dd4ce7196f3837ef68e1eff7d1584a8 --- /dev/null +++ b/db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb @@ -0,0 +1,19 @@ +# The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. +# It's easier to achieve this by adding the column with the `['api']` default (regular migration), and +# then changing the default to `[]` (in this post-migration). +# +# Details: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951#note_19721973 + +class ChangePersonalAccessTokensDefaultBackToEmptyArray < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + change_column_default :personal_access_tokens, :scopes, [].to_yaml + end + + def down + change_column_default :personal_access_tokens, :scopes, ['api'].to_yaml + end +end diff --git a/db/schema.rb b/db/schema.rb index 3786a41f9a96104653c62d63d1b0dc8a129d0034..14801b581e6e846ce3a2b2934e532d33731da7aa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -854,6 +854,7 @@ ActiveRecord::Schema.define(version: 20161213172958) do t.datetime "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "scopes", default: "--- []\n", null: false end add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree diff --git a/lib/api/api.rb b/lib/api/api.rb index cec2702e44d91fe71f39dee9c3177914c75895c9..9d5adffd8f4510864137aec6129edbfd0e129499 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -3,6 +3,8 @@ module API include APIGuard version 'v3', using: :path + before { allow_access_with_scope :api } + rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) end diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 8cc7a26f1fa71249e79d28f71308a5fdc097a251..df6db140d0e724e6292bbcce49a745054da8ea54 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -6,6 +6,9 @@ module API module APIGuard extend ActiveSupport::Concern + PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" + PRIVATE_TOKEN_PARAM = :private_token + included do |base| # OAuth2 Resource Server Authentication use Rack::OAuth2::Server::Resource::Bearer, 'The API' do |request| @@ -44,27 +47,60 @@ module API access_token = find_access_token return nil unless access_token - case validate_access_token(access_token, scopes) - when Oauth2::AccessTokenValidationService::INSUFFICIENT_SCOPE + case AccessTokenValidationService.new(access_token).validate(scopes: scopes) + when AccessTokenValidationService::INSUFFICIENT_SCOPE raise InsufficientScopeError.new(scopes) - when Oauth2::AccessTokenValidationService::EXPIRED + when AccessTokenValidationService::EXPIRED raise ExpiredError - when Oauth2::AccessTokenValidationService::REVOKED + when AccessTokenValidationService::REVOKED raise RevokedError - when Oauth2::AccessTokenValidationService::VALID + when AccessTokenValidationService::VALID @current_user = User.find(access_token.resource_owner_id) end end + def find_user_by_private_token(scopes: []) + token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + + return nil unless token_string.present? + + find_user_by_authentication_token(token_string) || find_user_by_personal_access_token(token_string, scopes) + end + def current_user @current_user end + # Set the authorization scope(s) allowed for the current request. + # + # Note: A call to this method adds to any previous scopes in place. This is done because + # `Grape` callbacks run from the outside-in: the top-level callback (API::API) runs first, then + # the next-level callback (API::API::Users, for example) runs. All these scopes are valid for the + # given endpoint (GET `/api/users` is accessible by the `api` and `read_user` scopes), and so they + # need to be stored. + def allow_access_with_scope(*scopes) + @scopes ||= [] + @scopes.concat(scopes.map(&:to_s)) + end + private + def find_user_by_authentication_token(token_string) + User.find_by_authentication_token(token_string) + end + + def find_user_by_personal_access_token(token_string, scopes) + access_token = PersonalAccessToken.active.find_by_token(token_string) + return unless access_token + + if AccessTokenValidationService.new(access_token).include_any_scope?(scopes) + User.find(access_token.user_id) + end + end + def find_access_token @access_token ||= Doorkeeper.authenticate(doorkeeper_request, Doorkeeper.configuration.access_token_methods) end @@ -72,10 +108,6 @@ module API def doorkeeper_request @doorkeeper_request ||= ActionDispatch::Request.new(env) end - - def validate_access_token(access_token, scopes) - Oauth2::AccessTokenValidationService.validate(access_token, scopes: scopes) - end end module ClassMethods diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 746849ef4c01d75bd12adfc9e9554f733154ff62..4be659fc20bd50df9a9ba4ff8b071b2e7bfafd25 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -2,8 +2,6 @@ module API module Helpers include Gitlab::Utils - PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" - PRIVATE_TOKEN_PARAM = :private_token SUDO_HEADER = "HTTP_SUDO" SUDO_PARAM = :sudo @@ -308,7 +306,7 @@ module API private def private_token - params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER] + params[APIGuard::PRIVATE_TOKEN_PARAM] || env[APIGuard::PRIVATE_TOKEN_HEADER] end def warden @@ -323,18 +321,11 @@ module API warden.try(:authenticate) if %w[GET HEAD].include?(env['REQUEST_METHOD']) end - def find_user_by_private_token - token = private_token - return nil unless token.present? - - User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) - end - def initial_current_user return @initial_current_user if defined?(@initial_current_user) - @initial_current_user ||= find_user_by_private_token - @initial_current_user ||= doorkeeper_guard + @initial_current_user ||= find_user_by_private_token(scopes: @scopes) + @initial_current_user ||= doorkeeper_guard(scopes: @scopes) @initial_current_user ||= find_user_from_warden unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed? diff --git a/lib/api/users.rb b/lib/api/users.rb index c7db2d7101715d324dcb8ba82c71a32e5a9dd9b0..0842c3874c5a4a981ac57f97125e064c0d741745 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -2,7 +2,10 @@ module API class Users < Grape::API include PaginationParams - before { authenticate! } + before do + allow_access_with_scope :read_user if request.get? + authenticate! + end resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do helpers do diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index aca5d0020cf5874313bcbac619dee3501ae77136..8dda65c71ef61644b1b1127598f81da5642b693b 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -2,6 +2,10 @@ module Gitlab module Auth class MissingPersonalTokenError < StandardError; end + SCOPES = [:api, :read_user] + DEFAULT_SCOPES = [:api] + OPTIONAL_SCOPES = SCOPES - DEFAULT_SCOPES + class << self def find_for_git_client(login, password, project:, ip:) raise "Must provide an IP for rate limiting" if ip.nil? @@ -88,7 +92,7 @@ module Gitlab def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) - if token && token.accessible? + if valid_oauth_token?(token) user = User.find_by(id: token.resource_owner_id) Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities) end @@ -97,12 +101,27 @@ module Gitlab def personal_access_token_check(login, password) if login && password - user = User.find_by_personal_access_token(password) + token = PersonalAccessToken.active.find_by_token(password) validation = User.by_login(login) - Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities) if user.present? && user == validation + + if valid_personal_access_token?(token, validation) + Gitlab::Auth::Result.new(validation, nil, :personal_token, full_authentication_abilities) + end end end + def valid_oauth_token?(token) + token && token.accessible? && valid_api_token?(token) + end + + def valid_personal_access_token?(token, user) + token && token.user == user && valid_api_token?(token) + end + + def valid_api_token?(token) + AccessTokenValidationService.new(token).include_any_scope?(['api']) + end + def lfs_token_check(login, password) deploy_key_matches = login.match(/\Alfs\+deploy-key-(\d+)\z/) diff --git a/spec/controllers/profiles/personal_access_tokens_spec.rb b/spec/controllers/profiles/personal_access_tokens_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..45534a3a5875abf205c55a853f753bf66024a5b8 --- /dev/null +++ b/spec/controllers/profiles/personal_access_tokens_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Profiles::PersonalAccessTokensController do + let(:user) { create(:user) } + + describe '#create' do + def created_token + PersonalAccessToken.order(:created_at).last + end + + before { sign_in(user) } + + it "allows creation of a token" do + name = FFaker::Product.brand + + post :create, personal_access_token: { name: name } + + expect(created_token).not_to be_nil + expect(created_token.name).to eq(name) + expect(created_token.expires_at).to be_nil + expect(PersonalAccessToken.active).to include(created_token) + end + + it "allows creation of a token with an expiry date" do + expires_at = 5.days.from_now + + post :create, personal_access_token: { name: FFaker::Product.brand, expires_at: expires_at } + + expect(created_token).not_to be_nil + expect(created_token.expires_at.to_i).to eq(expires_at.to_i) + end + + context "scopes" do + it "allows creation of a token with scopes" do + post :create, personal_access_token: { name: FFaker::Product.brand, scopes: ['api', 'read_user'] } + + expect(created_token).not_to be_nil + expect(created_token.scopes).to eq(['api', 'read_user']) + end + + it "allows creation of a token with no scopes" do + post :create, personal_access_token: { name: FFaker::Product.brand, scopes: [] } + + expect(created_token).not_to be_nil + expect(created_token.scopes).to eq([]) + end + end + end +end diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb index da4c72bcb5b32af4b0b41b2ba83b9f2c92ba21ba..811eab7e15bdc298660ec61554b0e667ddb53ddb 100644 --- a/spec/factories/personal_access_tokens.rb +++ b/spec/factories/personal_access_tokens.rb @@ -5,5 +5,6 @@ FactoryGirl.define do name { FFaker::Product.brand } revoked false expires_at { 5.days.from_now } + scopes ['api'] end end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index a85930c75434b0b629106c2ff8b1b33ddb29a82e..55a01057c83b25b1d7acea05b2033e3d3563f955 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -27,28 +27,25 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do describe "token creation" do it "allows creation of a token" do - visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand - - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to have_text("Never") - end + name = FFaker::Product.brand - it "allows creation of a token with an expiry date" do visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand + fill_in "Name", with: name # Set date to 1st of next month find_field("Expires at").trigger('focus') find("a[title='Next']").click click_on "1" - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) + # Scopes + check "api" + check "read_user" + + click_on "Create Personal Access Token" + expect(active_personal_access_tokens).to have_text(name) expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) + expect(active_personal_access_tokens).to have_text('api') + expect(active_personal_access_tokens).to have_text('read_user') end context "when creation fails" do @@ -85,7 +82,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do disallow_personal_access_token_saves! visit profile_personal_access_tokens_path - expect { click_on "Revoke" }.not_to change { PersonalAccessToken.inactive.count } + click_on "Revoke" expect(active_personal_access_tokens).to have_text(personal_access_token.name) expect(page).to have_content("Could not revoke") end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index c9d64e99f88f2d8756d9765c0241dd3e02b2af43..f251c0dd25a1d8cac3080d26e096a52b37627b6e 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -47,54 +47,76 @@ describe Gitlab::Auth, lib: true do project.create_drone_ci_service(active: true) project.drone_ci_service.update(token: 'token') - ip = 'ip' - - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'drone-ci-token') - expect(gl_auth.find_for_git_client('drone-ci-token', 'token', project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: 'drone-ci-token') + expect(gl_auth.find_for_git_client('drone-ci-token', 'token', project: project, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)) end it 'recognizes master passwords' do user = create(:user, password: 'password') - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.username) + expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) end it 'recognizes user lfs tokens' do user = create(:user) - ip = 'ip' token = Gitlab::LfsToken.new(user).token - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find_for_git_client(user.username, token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :lfs_token, full_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.username) + expect(gl_auth.find_for_git_client(user.username, token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :lfs_token, full_authentication_abilities)) end it 'recognizes deploy key lfs tokens' do key = create(:deploy_key) - ip = 'ip' token = Gitlab::LfsToken.new(key).token - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: "lfs+deploy-key-#{key.id}") - expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: "lfs+deploy-key-#{key.id}") + expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) end - it 'recognizes OAuth tokens' do - user = create(:user) - application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) - token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) - ip = 'ip' + context "while using OAuth tokens as passwords" do + it 'succeeds for OAuth tokens with the `api` scope' do + user = create(:user) + application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) + token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) + end - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') - expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) + it 'fails for OAuth tokens with other scopes' do + user = create(:user) + application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) + token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "read_user") + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + end + end + + context "while using personal access tokens as passwords" do + it 'succeeds for personal access tokens with the `api` scope' do + user = create(:user) + personal_access_token = create(:personal_access_token, user: user, scopes: ['api']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) + end + + it 'fails for personal access tokens with other scopes' do + user = create(:user) + personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + end end it 'returns double nil for invalid credentials' do login = 'foo' - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: login) - expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new) end end diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index 5262a623761545d84291e4672de894640a6957b6..bd9ecaf26856c99ed80de5920f426786efbddd80 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -5,7 +5,7 @@ describe API::API, api: true do let!(:user) { create(:user) } let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) } - let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id } + let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" } describe "when unauthenticated" do it "returns authentication success" do diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 4035fd97af59a52ff0eafccb8bfaf25d12774912..c3d7ac3eef8407ce4a1dd34124bf555bb51181f7 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe API::Helpers, api: true do + include API::APIGuard::HelperMethods include API::Helpers include SentryHelper @@ -15,24 +16,24 @@ describe API::Helpers, api: true do def set_env(user_or_token, identifier) clear_env clear_param - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token env[API::Helpers::SUDO_HEADER] = identifier.to_s end def set_param(user_or_token, identifier) clear_env clear_param - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token params[API::Helpers::SUDO_PARAM] = identifier.to_s end def clear_env - env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) + env.delete(API::APIGuard::PRIVATE_TOKEN_HEADER) env.delete(API::Helpers::SUDO_HEADER) end def clear_param - params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) + params.delete(API::APIGuard::PRIVATE_TOKEN_PARAM) params.delete(API::Helpers::SUDO_PARAM) end @@ -94,22 +95,28 @@ describe API::Helpers, api: true do describe "when authenticating using a user's private token" do it "returns nil for an invalid token" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil end it "returns nil for a user without access" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + expect(current_user).to be_nil end it "leaves user as is when sudo not specified" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token + expect(current_user).to eq(user) + clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token + + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user.private_token + expect(current_user).to eq(user) end end @@ -117,37 +124,51 @@ describe API::Helpers, api: true do describe "when authenticating using a user's personal access tokens" do let(:personal_access_token) { create(:personal_access_token, user: user) } + before do + allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } + end + it "returns nil for an invalid token" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' + expect(current_user).to be_nil end it "returns nil for a user without access" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + + expect(current_user).to be_nil + end + + it "returns nil for a token without the appropriate scope" do + personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + allow_access_with_scope('write_user') + expect(current_user).to be_nil end it "leaves user as is when sudo not specified" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = personal_access_token.token + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = personal_access_token.token + expect(current_user).to eq(user) end it 'does not allow revoked tokens' do personal_access_token.revoke! - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + expect(current_user).to be_nil end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + expect(current_user).to be_nil end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index f1728d61def1be85ce85a015b154e094b79a8274..d71bb08c218771274c09e3d23c2723d8183d5849 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -230,7 +230,7 @@ describe 'Git HTTP requests', lib: true do context "when an oauth token is provided" do before do application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) - @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) + @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") end it "downloads get status 200" do diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..87f093ee8ce9183a0e0434017bda56fdbe1d6559 --- /dev/null +++ b/spec/services/access_token_validation_service_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe AccessTokenValidationService, services: true do + describe ".include_any_scope?" do + it "returns true if the required scope is present in the token's scopes" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.new(token).include_any_scope?([:api])).to be(true) + end + + it "returns true if more than one of the required scopes is present in the token's scopes" do + token = double("token", scopes: [:api, :read_user, :other_scope]) + + expect(described_class.new(token).include_any_scope?([:api, :other_scope])).to be(true) + end + + it "returns true if the list of required scopes is an exact match for the token's scopes" do + token = double("token", scopes: [:api, :read_user, :other_scope]) + + expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true) + end + + it "returns true if the list of required scopes contains all of the token's scopes, in addition to others" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true) + end + + it 'returns true if the list of required scopes is blank' do + token = double("token", scopes: []) + + expect(described_class.new(token).include_any_scope?([])).to be(true) + end + + it "returns false if there are no scopes in common between the required scopes and the token scopes" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.new(token).include_any_scope?([:other_scope])).to be(false) + end + end +end