mirror of
https://github.com/wahyd4/gitlabhq.git
synced 2026-08-09 20:56:08 +10:00
Allow creating Personal Access Tokens through the website Related to #2979 - Allow a user to create personal access tokens, and use them to authenticate - Refactor `API::Helpers` into `API::Helpers::Core` and `API::Helpers::Authentication` # Tasks - [ ] #2979 (!3749) - Personal Access Tokens - [x] Basic Implementation - [x] Add UI to add "Personal Access Tokens" - [x] Reload `lib/api` on every request - [x] Respect these tokens for API requests - [x] Just a param or a header too? - [x] Allow revoking tokens - [x] Expire tokens - [x] Left bar should have a "PAT" icon - [x] Scopes? - [x] Copy to Clipboard - [x] Show active/inactive tokens separately - [x] No need to check for expired/revoked in the appropriate places - [x] Why does regular ApplicationController check for private token? - [x] Support non-API requests - [x] Revert (or work on) `lib/api` eager loading - [x] Create MR - [x] Refactoring - [x] Fix tests - [x] Write more tests - [x] Add screenshots to MR - [x] Add description of query performance to MR - [x] Limit the number of queries in the `personal_access_tokens` page - [x] Wait for CI to pass - [x] Fix merge issues in schema.rb - [x] Assign MR to endboss - [x] Wait for feedback - [x] Fix feedback - [x] Wait for CI to pass - [x] Assign to @rspeicher - [x] Fix @rspeicher's comments - [x] Wait for CI to pass - [x] Assign back to @rspeicher - [x] Write documentation and ping @axil - [x] Wait for Axil to respond - [x] Assign to endboss - [x] Address Douwe's feedback - [x] Use the `private_token` or `authentication_token` param instead of `personal_access_token` - [x] Ditto for the header - [x] Assign to endboss - [x] Make sure CI is green - [x] Address Douwe's feedback - [x] Don't go through the `authenticate_user_from_private_token!` method, if a private token is supplied (or combine them) - [x] In `authenticate_user_from_personal_access_token!` don't hit DB if `token_string` is `nil` - [x] Use `current_user.personal_access_tokens.build` in the controller - [x] Remove the "We aren't using `personal_access_token` as the root param" comment - [x] `No need for = "...", we can just have the Inactive ... #{...} on the next line` in the view - [x] Render dates in a (more) human format - [x] CSS issue with table - [x] Don't show the tokens in the UI indefinitely - [x] How to implement scopes? Add-on to current impl? Doorkeeper? - [x] Wait for @DouweM's comments about scopes - [x] Address @DouweM's second review - [x] Try not using `native['innerHTML']` - [x] use contexts for all "when ..." - [x] Ensure consistency (styling) with other pages for "You don't have any tokens" message - [x] "Actions" table column doesn't need a label - [x] %td can be moved outside of the if/else statement - [x] The header title should be "Profile Settings" - [x] Can this be a `before_create`, so we don't need to use `generate`? - [x] If it couldn't be revoked, will we show an error? - [x] If it couldn't be saved, will we show an error? - [x] Merge master - [x] Update CHANGELOG entry - [x] Add tests for form errors? - [x] Post screenshots - [x] Tag @jschatz1 for review - [x] Wait for [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/0dff6fd/builds) to pass - [x] Respond to @jschatz1's comments - [x] Hardcoded colors should be variables - [x] Should not be allowed to chose a date in the past - [x] Use the same table as in the Applications tab - [x] button should say "Create Personal Access Token" - [x] Float the revoke to the right on the `a` - [x] Change revocation message. "Are you sure you want to revoke this certificate? This action cannot be undone." - [x] Date stays selected and looks selected even though date is set as "never". - [x] ~~hover on the calendar button shifts~~ (not caused by this MR - happens on `milestones#new` as well) - [x] Don't use the panel for the created token - [x] Use a normal flash for "Your new personal access token has been created" - [x] Show the input (with the token) below it full width. - [x] Put the "Make sure you save it - you won't be able to access it again." message near the input - [x] Have the input highlight all on single click - [x] Update screenshots - [x] Merge master in + conflicts - [x] Assign to @jschatz1 again - [x] Respond to @jschatz1's comments - [x] No button for clipboard, only link - [x] text-danger - [x] highlight fade on that area where the token was created - [x] Make sure [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/d754d99179f1ffe846fcc1d8e858163b39efc5dc/builds) is green - [x] Assign to @jschatz1 - [x] Wait for [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/faa0e3f7580bc38d4d12916b4589c64d6c2678a7/builds) to pass - [x] Respond to @DouweM's feedback - [x] move the redirect_to out of the if/else - [x] certificate -> token - [x] datepicker back to text field - [x] combine the get_user_from_private_token and get_user_from_personal_access_token methods in ApplicationController - [x] combine the get_user_from_private_token and get_user_from_personal_access_token methods in `lib/api/helpers` - [x] don't need the new constants - [x] Wait for [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/9d7cda3ddce52baad9618466a5d00319b333be57/builds) to pass - [ ] Wait for merge # Screenshots     See merge request !3749
435 lines
12 KiB
Ruby
435 lines
12 KiB
Ruby
module API
|
|
module Helpers
|
|
PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN"
|
|
PRIVATE_TOKEN_PARAM = :private_token
|
|
SUDO_HEADER = "HTTP_SUDO"
|
|
SUDO_PARAM = :sudo
|
|
|
|
def parse_boolean(value)
|
|
[ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(value)
|
|
end
|
|
|
|
def find_user_by_private_token
|
|
token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s
|
|
User.find_by_authentication_token(token_string) || User.find_by_personal_access_token(token_string)
|
|
end
|
|
|
|
def current_user
|
|
@current_user ||= (find_user_by_private_token || doorkeeper_guard)
|
|
|
|
unless @current_user && Gitlab::UserAccess.allowed?(@current_user)
|
|
return nil
|
|
end
|
|
|
|
identifier = sudo_identifier()
|
|
|
|
# If the sudo is the current user do nothing
|
|
if identifier && !(@current_user.id == identifier || @current_user.username == identifier)
|
|
render_api_error!('403 Forbidden: Must be admin to use sudo', 403) unless @current_user.is_admin?
|
|
@current_user = User.by_username_or_id(identifier)
|
|
not_found!("No user id or username for: #{identifier}") if @current_user.nil?
|
|
end
|
|
|
|
@current_user
|
|
end
|
|
|
|
def sudo_identifier
|
|
identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER]
|
|
|
|
# Regex for integers
|
|
if !!(identifier =~ /\A[0-9]+\z/)
|
|
identifier.to_i
|
|
else
|
|
identifier
|
|
end
|
|
end
|
|
|
|
def user_project
|
|
@project ||= find_project(params[:id])
|
|
@project || not_found!("Project")
|
|
end
|
|
|
|
def find_project(id)
|
|
project = Project.find_with_namespace(id) || Project.find_by(id: id)
|
|
|
|
if project && can?(current_user, :read_project, project)
|
|
project
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def project_service
|
|
@project_service ||= begin
|
|
underscored_service = params[:service_slug].underscore
|
|
|
|
if Service.available_services_names.include?(underscored_service)
|
|
user_project.build_missing_services
|
|
|
|
service_method = "#{underscored_service}_service"
|
|
|
|
send_service(service_method)
|
|
end
|
|
end
|
|
|
|
@project_service || not_found!("Service")
|
|
end
|
|
|
|
def send_service(service_method)
|
|
user_project.send(service_method)
|
|
end
|
|
|
|
def service_attributes
|
|
@service_attributes ||= project_service.fields.inject([]) do |arr, hash|
|
|
arr << hash[:name].to_sym
|
|
end
|
|
end
|
|
|
|
def find_group(id)
|
|
begin
|
|
group = Group.find(id)
|
|
rescue ActiveRecord::RecordNotFound
|
|
group = Group.find_by!(path: id)
|
|
end
|
|
|
|
if can?(current_user, :read_group, group)
|
|
group
|
|
else
|
|
not_found!('Group')
|
|
end
|
|
end
|
|
|
|
def find_project_label(id)
|
|
label = user_project.labels.find_by_id(id) || user_project.labels.find_by_title(id)
|
|
label || not_found!('Label')
|
|
end
|
|
|
|
def find_project_issue(id)
|
|
issue = user_project.issues.find(id)
|
|
not_found! unless can?(current_user, :read_issue, issue)
|
|
issue
|
|
end
|
|
|
|
def paginate(relation)
|
|
relation.page(params[:page]).per(params[:per_page].to_i).tap do |data|
|
|
add_pagination_headers(data)
|
|
end
|
|
end
|
|
|
|
def authenticate!
|
|
unauthorized! unless current_user
|
|
end
|
|
|
|
def authenticate_by_gitlab_shell_token!
|
|
input = params['secret_token'].try(:chomp)
|
|
unless Devise.secure_compare(secret_token, input)
|
|
unauthorized!
|
|
end
|
|
end
|
|
|
|
def authenticated_as_admin!
|
|
forbidden! unless current_user.is_admin?
|
|
end
|
|
|
|
def authorize!(action, subject)
|
|
forbidden! unless abilities.allowed?(current_user, action, subject)
|
|
end
|
|
|
|
def authorize_push_project
|
|
authorize! :push_code, user_project
|
|
end
|
|
|
|
def authorize_admin_project
|
|
authorize! :admin_project, user_project
|
|
end
|
|
|
|
def require_gitlab_workhorse!
|
|
unless env['HTTP_GITLAB_WORKHORSE'].present?
|
|
forbidden!('Request should be executed via GitLab Workhorse')
|
|
end
|
|
end
|
|
|
|
def can?(object, action, subject)
|
|
abilities.allowed?(object, action, subject)
|
|
end
|
|
|
|
# Checks the occurrences of required attributes, each attribute must be present in the params hash
|
|
# or a Bad Request error is invoked.
|
|
#
|
|
# Parameters:
|
|
# keys (required) - A hash consisting of keys that must be present
|
|
def required_attributes!(keys)
|
|
keys.each do |key|
|
|
bad_request!(key) unless params[key].present?
|
|
end
|
|
end
|
|
|
|
def attributes_for_keys(keys, custom_params = nil)
|
|
params_hash = custom_params || params
|
|
attrs = {}
|
|
keys.each do |key|
|
|
if params_hash[key].present? or (params_hash.has_key?(key) and params_hash[key] == false)
|
|
attrs[key] = params_hash[key]
|
|
end
|
|
end
|
|
ActionController::Parameters.new(attrs).permit!
|
|
end
|
|
|
|
# Helper method for validating all labels against its names
|
|
def validate_label_params(params)
|
|
errors = {}
|
|
|
|
if params[:labels].present?
|
|
params[:labels].split(',').each do |label_name|
|
|
label = user_project.labels.create_with(
|
|
color: Label::DEFAULT_COLOR).find_or_initialize_by(
|
|
title: label_name.strip)
|
|
|
|
if label.invalid?
|
|
errors[label.title] = label.errors
|
|
end
|
|
end
|
|
end
|
|
|
|
errors
|
|
end
|
|
|
|
def validate_access_level?(level)
|
|
Gitlab::Access.options_with_owner.values.include? level.to_i
|
|
end
|
|
|
|
# Checks the occurrences of datetime attributes, each attribute if present in the params hash must be in ISO 8601
|
|
# format (YYYY-MM-DDTHH:MM:SSZ) or a Bad Request error is invoked.
|
|
#
|
|
# Parameters:
|
|
# keys (required) - An array consisting of elements that must be parseable as dates from the params hash
|
|
def datetime_attributes!(*keys)
|
|
keys.each do |key|
|
|
begin
|
|
params[key] = Time.xmlschema(params[key]) if params[key].present?
|
|
rescue ArgumentError
|
|
message = "\"" + key.to_s + "\" must be a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ"
|
|
render_api_error!(message, 400)
|
|
end
|
|
end
|
|
end
|
|
|
|
def issuable_order_by
|
|
if params["order_by"] == 'updated_at'
|
|
'updated_at'
|
|
else
|
|
'created_at'
|
|
end
|
|
end
|
|
|
|
def issuable_sort
|
|
if params["sort"] == 'asc'
|
|
:asc
|
|
else
|
|
:desc
|
|
end
|
|
end
|
|
|
|
def filter_by_iid(items, iid)
|
|
items.where(iid: iid)
|
|
end
|
|
|
|
# error helpers
|
|
|
|
def forbidden!(reason = nil)
|
|
message = ['403 Forbidden']
|
|
message << " - #{reason}" if reason
|
|
render_api_error!(message.join(' '), 403)
|
|
end
|
|
|
|
def bad_request!(attribute)
|
|
message = ["400 (Bad request)"]
|
|
message << "\"" + attribute.to_s + "\" not given"
|
|
render_api_error!(message.join(' '), 400)
|
|
end
|
|
|
|
def not_found!(resource = nil)
|
|
message = ["404"]
|
|
message << resource if resource
|
|
message << "Not Found"
|
|
render_api_error!(message.join(' '), 404)
|
|
end
|
|
|
|
def unauthorized!
|
|
render_api_error!('401 Unauthorized', 401)
|
|
end
|
|
|
|
def not_allowed!
|
|
render_api_error!('405 Method Not Allowed', 405)
|
|
end
|
|
|
|
def conflict!(message = nil)
|
|
render_api_error!(message || '409 Conflict', 409)
|
|
end
|
|
|
|
def file_to_large!
|
|
render_api_error!('413 Request Entity Too Large', 413)
|
|
end
|
|
|
|
def not_modified!
|
|
render_api_error!('304 Not Modified', 304)
|
|
end
|
|
|
|
def render_validation_error!(model)
|
|
if model.errors.any?
|
|
render_api_error!(model.errors.messages || '400 Bad Request', 400)
|
|
end
|
|
end
|
|
|
|
def render_api_error!(message, status)
|
|
error!({ 'message' => message }, status)
|
|
end
|
|
|
|
# Projects helpers
|
|
|
|
def filter_projects(projects)
|
|
# If the archived parameter is passed, limit results accordingly
|
|
if params[:archived].present?
|
|
projects = projects.where(archived: parse_boolean(params[:archived]))
|
|
end
|
|
|
|
if params[:search].present?
|
|
projects = projects.search(params[:search])
|
|
end
|
|
|
|
if params[:visibility].present?
|
|
projects = projects.search_by_visibility(params[:visibility])
|
|
end
|
|
|
|
projects.reorder(project_order_by => project_sort)
|
|
end
|
|
|
|
def project_order_by
|
|
order_fields = %w(id name path created_at updated_at last_activity_at)
|
|
|
|
if order_fields.include?(params['order_by'])
|
|
params['order_by']
|
|
else
|
|
'created_at'
|
|
end
|
|
end
|
|
|
|
def project_sort
|
|
if params["sort"] == 'asc'
|
|
:asc
|
|
else
|
|
:desc
|
|
end
|
|
end
|
|
|
|
# file helpers
|
|
|
|
def uploaded_file(field, uploads_path)
|
|
if params[field]
|
|
bad_request!("#{field} is not a file") unless params[field].respond_to?(:filename)
|
|
return params[field]
|
|
end
|
|
|
|
return nil unless params["#{field}.path"] && params["#{field}.name"]
|
|
|
|
# sanitize file paths
|
|
# this requires all paths to exist
|
|
required_attributes! %W(#{field}.path)
|
|
uploads_path = File.realpath(uploads_path)
|
|
file_path = File.realpath(params["#{field}.path"])
|
|
bad_request!('Bad file path') unless file_path.start_with?(uploads_path)
|
|
|
|
UploadedFile.new(
|
|
file_path,
|
|
params["#{field}.name"],
|
|
params["#{field}.type"] || 'application/octet-stream',
|
|
)
|
|
end
|
|
|
|
def present_file!(path, filename, content_type = 'application/octet-stream')
|
|
filename ||= File.basename(path)
|
|
header['Content-Disposition'] = "attachment; filename=#{filename}"
|
|
header['Content-Transfer-Encoding'] = 'binary'
|
|
content_type content_type
|
|
|
|
# Support download acceleration
|
|
case headers['X-Sendfile-Type']
|
|
when 'X-Sendfile'
|
|
header['X-Sendfile'] = path
|
|
body
|
|
else
|
|
file FileStreamer.new(path)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def add_pagination_headers(paginated_data)
|
|
header 'X-Total', paginated_data.total_count.to_s
|
|
header 'X-Total-Pages', paginated_data.total_pages.to_s
|
|
header 'X-Per-Page', paginated_data.limit_value.to_s
|
|
header 'X-Page', paginated_data.current_page.to_s
|
|
header 'X-Next-Page', paginated_data.next_page.to_s
|
|
header 'X-Prev-Page', paginated_data.prev_page.to_s
|
|
header 'Link', pagination_links(paginated_data)
|
|
end
|
|
|
|
def pagination_links(paginated_data)
|
|
request_url = request.url.split('?').first
|
|
request_params = params.clone
|
|
request_params[:per_page] = paginated_data.limit_value
|
|
|
|
links = []
|
|
|
|
request_params[:page] = paginated_data.current_page - 1
|
|
links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page?
|
|
|
|
request_params[:page] = paginated_data.current_page + 1
|
|
links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page?
|
|
|
|
request_params[:page] = 1
|
|
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
|
|
|
|
request_params[:page] = paginated_data.total_pages
|
|
links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
|
|
|
|
links.join(', ')
|
|
end
|
|
|
|
def abilities
|
|
@abilities ||= begin
|
|
abilities = Six.new
|
|
abilities << Ability
|
|
abilities
|
|
end
|
|
end
|
|
|
|
def secret_token
|
|
File.read(Gitlab.config.gitlab_shell.secret_file).chomp
|
|
end
|
|
|
|
def handle_member_errors(errors)
|
|
error!(errors[:access_level], 422) if errors[:access_level].any?
|
|
not_found!(errors)
|
|
end
|
|
|
|
def send_git_blob(repository, blob)
|
|
env['api.format'] = :txt
|
|
content_type 'text/plain'
|
|
header(*Gitlab::Workhorse.send_git_blob(repository, blob))
|
|
end
|
|
|
|
def send_git_archive(repository, ref:, format:)
|
|
header(*Gitlab::Workhorse.send_git_archive(repository, ref: ref, format: format))
|
|
end
|
|
|
|
def issue_entity(project)
|
|
if project.has_external_issue_tracker?
|
|
Entities::ExternalIssue
|
|
else
|
|
Entities::Issue
|
|
end
|
|
end
|
|
end
|
|
end
|