Feature #43881 » personal-access-tokens-43881.patch
| app/controllers/application_controller.rb | ||
|---|---|---|
| 62 | 62 |
end |
| 63 | 63 | |
| 64 | 64 |
before_action :api_session, :session_expiration, :user_setup, :check_if_login_required, :set_localization, :check_password_change, :check_twofa_activation |
| 65 |
# Wraps the whole filter chain so that denied requests (halted by a |
|
| 66 |
# before_action) are audited too |
|
| 67 |
prepend_around_action :log_api_request |
|
| 65 | 68 |
after_action :record_project_usage |
| 66 | 69 | |
| 67 | 70 |
rescue_from ::Unauthorized, :with => :deny_access |
| ... | ... | |
| 142 | 145 |
end |
| 143 | 146 |
if user.nil? && Setting.rest_api_enabled? && accept_api_auth? |
| 144 | 147 |
if (key = api_key_from_request) |
| 145 |
# Use API key |
|
| 146 |
user = User.find_by_api_key(key) |
|
| 148 |
# Personal access token or legacy API key |
|
| 149 |
if (personal_access_token = PersonalAccessToken.find_active(key)) |
|
| 150 |
user = user_from_personal_access_token(personal_access_token) |
|
| 151 |
@api_auth_credential = "pat:#{personal_access_token.id}"
|
|
| 152 |
elsif (user = User.find_by_api_key(key)) |
|
| 153 |
@api_auth_credential = 'api_key' |
|
| 154 |
end |
|
| 147 | 155 |
elsif access_token = Doorkeeper.authenticate(request) |
| 148 | 156 |
# Oauth |
| 149 | 157 |
if access_token.accessible? |
| 150 | 158 |
user = User.active.find_by_id(access_token.resource_owner_id) |
| 151 | 159 |
user.oauth_scope = access_token.scopes.all.map(&:to_sym) |
| 160 |
@api_auth_credential = "oauth:#{access_token.id}"
|
|
| 152 | 161 |
else |
| 153 | 162 |
doorkeeper_render_error |
| 154 | 163 |
end |
| ... | ... | |
| 162 | 171 |
return |
| 163 | 172 |
end |
| 164 | 173 | |
| 165 |
user ||= User.find_by_api_key(username) |
|
| 174 |
if user.nil? |
|
| 175 |
if (personal_access_token = PersonalAccessToken.find_active(username)) |
|
| 176 |
user = user_from_personal_access_token(personal_access_token) |
|
| 177 |
@api_auth_credential = "pat:#{personal_access_token.id}"
|
|
| 178 |
elsif (user = User.find_by_api_key(username)) |
|
| 179 |
@api_auth_credential = 'api_key' |
|
| 180 |
end |
|
| 181 |
end |
|
| 166 | 182 |
end |
| 167 | 183 |
if user && user.must_change_password? |
| 168 | 184 |
render_error :message => 'You must change your password', :status => 403 |
| ... | ... | |
| 737 | 753 |
%w(xml json).include? params[:format] |
| 738 | 754 |
end |
| 739 | 755 | |
| 756 |
# Writes a structured audit line for requests authenticated with an API |
|
| 757 |
# credential (personal access token, legacy API key or OAuth token). |
|
| 758 |
# The request path is logged without the query string, which may carry |
|
| 759 |
# a plaintext key. |
|
| 760 |
def log_api_request |
|
| 761 |
yield |
|
| 762 |
ensure |
|
| 763 |
# When an exception is in flight the client will receive a 500 from the |
|
| 764 |
# exception-handling middleware, not the status currently on response |
|
| 765 |
write_api_audit_entry($! ? 500 : response.status) |
|
| 766 |
end |
|
| 767 | ||
| 768 |
def write_api_audit_entry(status) |
|
| 769 |
return unless @api_auth_credential |
|
| 770 |
return unless Setting.api_audit_logging_enabled? |
|
| 771 | ||
| 772 |
Redmine::ApiAudit.log( |
|
| 773 |
'at' => Time.now.utc.iso8601, |
|
| 774 |
'user_id' => User.current.id, |
|
| 775 |
'user' => User.current.login, |
|
| 776 |
'credential' => @api_auth_credential, |
|
| 777 |
'method' => request.request_method, |
|
| 778 |
'path' => request.path, |
|
| 779 |
'ip' => request.remote_ip, |
|
| 780 |
'status' => status |
|
| 781 |
) |
|
| 782 |
rescue => e |
|
| 783 |
# Audit logging must degrade silently: a full disk or unwritable log |
|
| 784 |
# directory should not turn into API failures (we run inside an ensure, |
|
| 785 |
# so raising here would also mask the original response or exception) |
|
| 786 |
logger&.error("Unable to write API audit entry: #{e.class}: #{e.message}")
|
|
| 787 |
end |
|
| 788 | ||
| 789 |
# Returns the user authenticated by the given personal access token, |
|
| 790 |
# restricted to the token's scopes when it has any (reusing the OAuth |
|
| 791 |
# scope enforcement in User#allowed_to? and User#admin?) |
|
| 792 |
def user_from_personal_access_token(token) |
|
| 793 |
user = token.user |
|
| 794 |
user.oauth_scope = token.scope_list if token.scopes.present? |
|
| 795 |
user |
|
| 796 |
end |
|
| 797 | ||
| 740 | 798 |
# Returns the API key present in the request |
| 741 | 799 |
def api_key_from_request |
| 742 | 800 |
if params[:key].present? |
| app/controllers/personal_access_tokens_controller.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
class PersonalAccessTokensController < ApplicationController |
|
| 21 |
self.main_menu = false |
|
| 22 | ||
| 23 |
before_action :require_login |
|
| 24 |
before_action :find_token, :only => :destroy |
|
| 25 |
require_sudo_mode :create, :destroy |
|
| 26 | ||
| 27 |
def index |
|
| 28 |
@tokens = User.current.personal_access_tokens.order(:id).to_a |
|
| 29 |
# One-time display of a value generated by the create action. Deleted |
|
| 30 |
# from the flash so that render_flash_messages does not repeat it. |
|
| 31 |
@new_token_value = flash[:personal_access_token_value] |
|
| 32 |
flash.delete(:personal_access_token_value) |
|
| 33 |
end |
|
| 34 | ||
| 35 |
helper_method :default_expiration_days |
|
| 36 | ||
| 37 |
def new |
|
| 38 |
@token = PersonalAccessToken.new(:expires_on => default_expiration_days.days.from_now.to_date) |
|
| 39 |
end |
|
| 40 | ||
| 41 |
def create |
|
| 42 |
@token = PersonalAccessToken.new(:user => User.current) |
|
| 43 |
@token.safe_attributes = params[:personal_access_token] |
|
| 44 |
if @token.save |
|
| 45 |
flash[:personal_access_token_value] = @token.plaintext_value |
|
| 46 |
flash[:notice] = l(:notice_personal_access_token_created, :name => @token.name) |
|
| 47 |
redirect_to my_personal_access_tokens_path |
|
| 48 |
else |
|
| 49 |
render :action => 'new' |
|
| 50 |
end |
|
| 51 |
end |
|
| 52 | ||
| 53 |
def destroy |
|
| 54 |
@token.destroy |
|
| 55 |
flash[:notice] = l(:notice_successful_delete) |
|
| 56 |
redirect_to my_personal_access_tokens_path |
|
| 57 |
end |
|
| 58 | ||
| 59 |
private |
|
| 60 | ||
| 61 |
# Default expiration offered by the form, clamped to the admin |
|
| 62 |
# max-lifetime policy so the prefilled date is always valid |
|
| 63 |
def default_expiration_days |
|
| 64 |
max = Setting.personal_access_token_max_lifetime.to_i |
|
| 65 |
max > 0 ? [30, max].min : 30 |
|
| 66 |
end |
|
| 67 | ||
| 68 |
def find_token |
|
| 69 |
@token = User.current.personal_access_tokens.find(params[:id]) |
|
| 70 |
rescue ActiveRecord::RecordNotFound |
|
| 71 |
render_404 |
|
| 72 |
end |
|
| 73 |
end |
|
| app/models/personal_access_token.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
class PersonalAccessToken < ApplicationRecord |
|
| 21 |
include Redmine::SafeAttributes |
|
| 22 | ||
| 23 |
belongs_to :user |
|
| 24 | ||
| 25 |
# Plaintext token values are "rmpat_" followed by 40 hex characters. |
|
| 26 |
# Only the SHA256 digest of the full value is persisted. |
|
| 27 |
TOKEN_PREFIX = 'rmpat_' |
|
| 28 | ||
| 29 |
# Delay between two updates of last_used_on for the same token |
|
| 30 |
LAST_USED_THROTTLE = 1.hour |
|
| 31 | ||
| 32 |
validates_presence_of :name, :expires_on |
|
| 33 |
validates_length_of :name, maximum: 255 |
|
| 34 |
validates_uniqueness_of :name, scope: :user_id, case_sensitive: true |
|
| 35 |
validate :validate_expires_on |
|
| 36 |
validate :validate_scopes |
|
| 37 | ||
| 38 |
before_save :include_public_permissions_in_scopes |
|
| 39 |
before_create :generate_value |
|
| 40 | ||
| 41 |
safe_attributes 'name', 'expires_on', 'scopes' |
|
| 42 | ||
| 43 |
# The plaintext value, only available on the instance that created it |
|
| 44 |
attr_reader :plaintext_value |
|
| 45 | ||
| 46 |
# Creates a token for +user+ and returns [record, plaintext value]. |
|
| 47 |
# The plaintext value cannot be retrieved afterwards. |
|
| 48 |
def self.generate!(user:, name:, expires_on:, scopes: nil) |
|
| 49 |
token = create!(user: user, name: name, expires_on: expires_on, scopes: scopes) |
|
| 50 |
[token, token.plaintext_value] |
|
| 51 |
end |
|
| 52 | ||
| 53 |
# Converts legacy plaintext API keys (Token rows with action='api') into |
|
| 54 |
# hashed personal access tokens and deletes the plaintext rows. The keys |
|
| 55 |
# keep authenticating unchanged since lookup is done by digest. Intended |
|
| 56 |
# to be called by the data migration of the release that removes legacy |
|
| 57 |
# API keys (kept unit-tested here until then). Irreversible by design. |
|
| 58 |
def self.import_legacy_api_tokens!(grace_days: nil) |
|
| 59 |
grace_days ||= Setting.personal_access_token_max_lifetime.to_i |
|
| 60 |
grace_days = 365 if grace_days <= 0 |
|
| 61 | ||
| 62 |
Token.where(:action => 'api').find_each do |legacy| |
|
| 63 |
digest = hash_value(legacy.value) |
|
| 64 |
next if exists?(:token_digest => digest) |
|
| 65 |
next unless legacy.user |
|
| 66 | ||
| 67 |
transaction do |
|
| 68 |
create!( |
|
| 69 |
:user => legacy.user, |
|
| 70 |
:name => available_import_name(legacy.user_id), |
|
| 71 |
:expires_on => grace_days.days.from_now.to_date, |
|
| 72 |
:token_digest => digest |
|
| 73 |
) |
|
| 74 |
legacy.destroy |
|
| 75 |
end |
|
| 76 |
end |
|
| 77 |
end |
|
| 78 | ||
| 79 |
def self.hash_value(plaintext) |
|
| 80 |
Digest::SHA256.hexdigest(plaintext) |
|
| 81 |
end |
|
| 82 | ||
| 83 |
# Returns the token matching the given plaintext value, or nil when the |
|
| 84 |
# token is unknown, expired or its user is not active. |
|
| 85 |
# |
|
| 86 |
# Equality lookup on the digest is sufficient here: the digest is computed |
|
| 87 |
# server-side from the presented value, so there is no user-controlled |
|
| 88 |
# stored value that a case-insensitive collation could mis-match (unlike |
|
| 89 |
# Token.find_token, which re-checks with secure_compare for that reason), |
|
| 90 |
# and a timing side channel would only compare two SHA256 digests. |
|
| 91 |
# |
|
| 92 |
# Note: this finder deliberately writes (throttled last_used_on tracking) - |
|
| 93 |
# authenticating a request is the usage being recorded. |
|
| 94 |
def self.find_active(plaintext) |
|
| 95 |
return nil if plaintext.blank? |
|
| 96 | ||
| 97 |
token = find_by(token_digest: hash_value(plaintext.to_s)) |
|
| 98 |
return nil unless token |
|
| 99 |
return nil if token.expired? |
|
| 100 |
return nil unless token.user&.active? |
|
| 101 | ||
| 102 |
token.touch_last_used_on |
|
| 103 |
token |
|
| 104 |
end |
|
| 105 | ||
| 106 |
# Returns the active user owning the given plaintext token value |
|
| 107 |
def self.find_active_user(plaintext) |
|
| 108 |
find_active(plaintext)&.user |
|
| 109 |
end |
|
| 110 | ||
| 111 |
# Permission names a token may be restricted to, mirroring the scopes |
|
| 112 |
# accepted for OAuth applications (all permissions plus 'admin') |
|
| 113 |
def self.valid_scope_names |
|
| 114 |
Redmine::AccessControl.permissions.map {|p| p.name.to_s} + ['admin']
|
|
| 115 |
end |
|
| 116 | ||
| 117 |
# Accepts an array of scope names (as submitted by the form checkboxes) |
|
| 118 |
# and stores them as a space-separated string, like OAuth scopes |
|
| 119 |
def scopes=(value) |
|
| 120 |
value = value.reject(&:blank?).join(' ') if value.is_a?(Array)
|
|
| 121 |
super |
|
| 122 |
end |
|
| 123 | ||
| 124 |
# Returns the scopes as an array of symbols, empty when unrestricted |
|
| 125 |
def scope_list |
|
| 126 |
scopes.to_s.split.map(&:to_sym) |
|
| 127 |
end |
|
| 128 | ||
| 129 |
def expired? |
|
| 130 |
expires_on < Date.today |
|
| 131 |
end |
|
| 132 | ||
| 133 |
def touch_last_used_on |
|
| 134 |
if last_used_on.nil? || last_used_on < LAST_USED_THROTTLE.ago |
|
| 135 |
update_column(:last_used_on, Time.now) |
|
| 136 |
end |
|
| 137 |
end |
|
| 138 | ||
| 139 |
def self.available_import_name(user_id) |
|
| 140 |
name = 'Migrated legacy API key' |
|
| 141 |
i = 1 |
|
| 142 |
while exists?(:user_id => user_id, :name => name) |
|
| 143 |
i += 1 |
|
| 144 |
name = "Migrated legacy API key #{i}"
|
|
| 145 |
end |
|
| 146 |
name |
|
| 147 |
end |
|
| 148 |
private_class_method :available_import_name |
|
| 149 | ||
| 150 |
private |
|
| 151 | ||
| 152 |
def generate_value |
|
| 153 |
return if token_digest.present? |
|
| 154 | ||
| 155 |
@plaintext_value = TOKEN_PREFIX + Redmine::Utils.random_hex(20) |
|
| 156 |
self.token_digest = self.class.hash_value(@plaintext_value) |
|
| 157 |
end |
|
| 158 | ||
| 159 |
def validate_scopes |
|
| 160 |
return if scopes.blank? |
|
| 161 | ||
| 162 |
unknown = scope_list.map(&:to_s) - self.class.valid_scope_names |
|
| 163 |
errors.add(:scopes, :invalid) if unknown.any? |
|
| 164 |
end |
|
| 165 | ||
| 166 |
# A restricted token must still allow what is public to everyone, |
|
| 167 |
# mirroring Oauth2ApplicationsController#application_params |
|
| 168 |
def include_public_permissions_in_scopes |
|
| 169 |
if scopes.present? |
|
| 170 |
public_names = Redmine::AccessControl.public_permissions.map {|p| p.name.to_s}
|
|
| 171 |
self.scopes = (scope_list.map(&:to_s) | public_names).join(' ')
|
|
| 172 |
end |
|
| 173 |
end |
|
| 174 | ||
| 175 |
def validate_expires_on |
|
| 176 |
return if expires_on.blank? |
|
| 177 | ||
| 178 |
max_lifetime = Setting.personal_access_token_max_lifetime.to_i |
|
| 179 |
if expires_on < Date.today || |
|
| 180 |
(max_lifetime > 0 && expires_on > max_lifetime.days.from_now.to_date) |
|
| 181 |
errors.add(:expires_on, :invalid) |
|
| 182 |
end |
|
| 183 |
end |
|
| 184 |
end |
|
| app/models/user.rb | ||
|---|---|---|
| 101 | 101 |
has_one :preference, :dependent => :destroy, :class_name => 'UserPreference' |
| 102 | 102 |
has_one :atom_token, lambda {where "#{table.name}.action='feeds'"}, :class_name => 'Token'
|
| 103 | 103 |
has_one :api_token, lambda {where "#{table.name}.action='api'"}, :class_name => 'Token'
|
| 104 |
has_many :personal_access_tokens, :dependent => :delete_all |
|
| 104 | 105 |
has_many :email_addresses, :dependent => :delete_all |
| 105 | 106 |
has_many :reactions, dependent: :delete_all |
| 106 | 107 |
has_many :webhooks, dependent: :destroy |
| app/views/my/_sidebar.html.erb | ||
|---|---|---|
| 24 | 24 |
</p> |
| 25 | 25 | |
| 26 | 26 |
<% if Setting.rest_api_enabled? %> |
| 27 |
<h4><%= l(:label_api_access_key) %></h4> |
|
| 27 |
<h4><%= l(:label_personal_access_token_plural) %></h4> |
|
| 28 |
<p><%= link_to l(:label_personal_access_token_plural), my_personal_access_tokens_path %></p> |
|
| 29 | ||
| 30 |
<h4><%= l(:label_api_access_key) %> <%= l(:label_legacy) %></h4> |
|
| 28 | 31 |
<div data-controller="api-key-copy"> |
| 29 | 32 |
<div class="api-key-actions"> |
| 30 | 33 |
<%= link_to l(:button_show), my_api_key_path, :remote => true %> |
| app/views/my/account.html.erb | ||
|---|---|---|
| 2 | 2 |
<%= additional_emails_link(@user) %> |
| 3 | 3 |
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
|
| 4 | 4 |
<%= link_to sprite_icon('webhook', l(:label_webhook_plural)), webhooks_path, class: 'icon icon-webhook' if Webhook.enabled? && @user.allowed_to?(:use_webhooks, nil, global: true) %>
|
| 5 |
<%= link_to(sprite_icon('key', l(:label_personal_access_token_plural)), my_personal_access_tokens_path, :class => 'icon icon-passwd') if Setting.rest_api_enabled? %>
|
|
| 5 | 6 |
<%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %>
|
| 6 | 7 |
<%= call_hook(:view_my_account_contextual, :user => @user)%> |
| 7 | 8 |
</div> |
| app/views/personal_access_tokens/index.html.erb | ||
|---|---|---|
| 1 |
<div class="contextual"> |
|
| 2 |
<%= link_to sprite_icon('add', l(:label_personal_access_token_new)), new_my_personal_access_token_path, :class => 'icon icon-add' %>
|
|
| 3 |
</div> |
|
| 4 | ||
| 5 |
<%= title [l(:label_my_account), my_account_path], l(:label_personal_access_token_plural) %> |
|
| 6 | ||
| 7 |
<p><%= l(:text_personal_access_tokens_info) %></p> |
|
| 8 | ||
| 9 |
<% if @new_token_value %> |
|
| 10 |
<div class="box"> |
|
| 11 |
<p><strong><%= l(:text_personal_access_token_show_once) %></strong></p> |
|
| 12 |
<pre class="personal-access-token-value"><%= @new_token_value %></pre> |
|
| 13 |
</div> |
|
| 14 |
<% end %> |
|
| 15 | ||
| 16 |
<% if @tokens.any? %> |
|
| 17 |
<div class="autoscroll"> |
|
| 18 |
<table class="list"> |
|
| 19 |
<thead> |
|
| 20 |
<tr> |
|
| 21 |
<th class="name"><%= l(:field_name) %></th> |
|
| 22 |
<th><%= l(:field_created_on) %></th> |
|
| 23 |
<th><%= l(:field_expires_on) %></th> |
|
| 24 |
<th><%= l(:field_scopes) %></th> |
|
| 25 |
<th><%= l(:label_last_used) %></th> |
|
| 26 |
<th></th> |
|
| 27 |
</tr> |
|
| 28 |
</thead> |
|
| 29 |
<tbody> |
|
| 30 |
<% @tokens.each do |token| %> |
|
| 31 |
<tr id="personal-access-token-<%= token.id %>" class="<%= cycle('odd', 'even') %>">
|
|
| 32 |
<td class="name"><%= token.name %></td> |
|
| 33 |
<td><%= format_date(token.created_at) %></td> |
|
| 34 |
<td> |
|
| 35 |
<%= format_date(token.expires_on) %> |
|
| 36 |
<% if token.expired? %>(<%= l(:label_token_expired) %>)<% end %> |
|
| 37 |
</td> |
|
| 38 |
<td class="scopes"> |
|
| 39 |
<% if token.scopes.present? %> |
|
| 40 |
<span title="<%= token.scope_list.join(', ') %>"><%= token.scope_list.size %></span>
|
|
| 41 |
<% else %> |
|
| 42 |
<%= l(:label_scopes_full_access) %> |
|
| 43 |
<% end %> |
|
| 44 |
</td> |
|
| 45 |
<td><%= token.last_used_on ? time_tag(token.last_used_on) : l(:label_never_used) %></td> |
|
| 46 |
<td class="buttons"> |
|
| 47 |
<%= delete_link my_personal_access_token_path(token), {}, l(token.expired? ? :button_delete : :button_revoke) %>
|
|
| 48 |
</td> |
|
| 49 |
</tr> |
|
| 50 |
<% end %> |
|
| 51 |
</tbody> |
|
| 52 |
</table> |
|
| 53 |
</div> |
|
| 54 |
<% else %> |
|
| 55 |
<p class="nodata"><%= l(:label_no_data) %></p> |
|
| 56 |
<% end %> |
|
| 57 | ||
| 58 |
<% content_for :sidebar do %> |
|
| 59 |
<% @user = User.current %> |
|
| 60 |
<%= render :partial => 'my/sidebar' %> |
|
| 61 |
<% end %> |
|
| app/views/personal_access_tokens/new.html.erb | ||
|---|---|---|
| 1 |
<%= title [l(:label_my_account), my_account_path], |
|
| 2 |
[l(:label_personal_access_token_plural), my_personal_access_tokens_path], |
|
| 3 |
l(:label_personal_access_token_new) %> |
|
| 4 | ||
| 5 |
<%= labelled_form_for @token, :url => my_personal_access_tokens_path do |f| %> |
|
| 6 |
<%= error_messages_for @token %> |
|
| 7 |
<div class="box tabular"> |
|
| 8 |
<p> |
|
| 9 |
<%= f.text_field :name, :required => true, :size => 40 %> |
|
| 10 |
<em class="info"><%= l(:text_personal_access_token_name_info) %></em> |
|
| 11 |
</p> |
|
| 12 |
<p> |
|
| 13 |
<%= f.date_field :expires_on, :required => true %> |
|
| 14 |
<em class="info"> |
|
| 15 |
<%= l(:text_personal_access_token_expiration_info, :days => default_expiration_days) %> |
|
| 16 |
<% if Setting.personal_access_token_max_lifetime.to_i > 0 %> |
|
| 17 |
<%= l(:text_personal_access_token_max_lifetime_info, :max => Setting.personal_access_token_max_lifetime.to_i) %> |
|
| 18 |
<% end %> |
|
| 19 |
</em> |
|
| 20 |
</p> |
|
| 21 |
</div> |
|
| 22 | ||
| 23 |
<h3><%= l(:field_scopes) %></h3> |
|
| 24 |
<p><em class="info"><%= l(:text_personal_access_token_scopes_info) %></em></p> |
|
| 25 |
<div class="box tabular" id="scopes"> |
|
| 26 |
<fieldset><legend><%= l(:label_administration) %></legend> |
|
| 27 |
<label class="floating" style="width: auto;"> |
|
| 28 |
<%= check_box_tag 'personal_access_token[scopes][]', 'admin', @token.scope_list.include?(:admin), |
|
| 29 |
:id => 'personal_access_token_scopes_admin' %> |
|
| 30 |
<%= l(:text_personal_access_token_admin_scope) %> |
|
| 31 |
</label> |
|
| 32 |
</fieldset> |
|
| 33 |
<% perms_by_module = Redmine::AccessControl.permissions.group_by {|p| p.project_module.to_s} %>
|
|
| 34 |
<% perms_by_module.keys.sort.each do |mod| %> |
|
| 35 |
<fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend> |
|
| 36 |
<% perms_by_module[mod].each do |permission| %> |
|
| 37 |
<label class="floating"> |
|
| 38 |
<%= check_box_tag 'personal_access_token[scopes][]', permission.name.to_s, |
|
| 39 |
(permission.public? || @token.scope_list.include?(permission.name)), |
|
| 40 |
:id => "personal_access_token_scopes_#{permission.name}",
|
|
| 41 |
:disabled => permission.public? %> |
|
| 42 |
<%= l_or_humanize(permission.name, :prefix => 'permission_') %> |
|
| 43 |
</label> |
|
| 44 |
<% end %> |
|
| 45 |
</fieldset> |
|
| 46 |
<% end %> |
|
| 47 |
<br /><%= check_all_links 'scopes' %> |
|
| 48 |
<%= hidden_field_tag 'personal_access_token[scopes][]', '' %> |
|
| 49 |
</div> |
|
| 50 | ||
| 51 |
<%= submit_tag l(:button_create) %> |
|
| 52 |
<%= link_to l(:button_cancel), my_personal_access_tokens_path %> |
|
| 53 |
<% end %> |
|
| 54 | ||
| 55 |
<% content_for :sidebar do %> |
|
| 56 |
<% @user = User.current %> |
|
| 57 |
<%= render :partial => 'my/sidebar' %> |
|
| 58 |
<% end %> |
|
| app/views/settings/_api.html.erb | ||
|---|---|---|
| 6 | 6 |
<p><%= setting_check_box :jsonp_enabled %></p> |
| 7 | 7 | |
| 8 | 8 |
<p><%= setting_check_box :webhooks_enabled %></p> |
| 9 | ||
| 10 |
<p><%= setting_text_field :personal_access_token_max_lifetime, :size => 6 %> |
|
| 11 |
<em class="info"><%= l(:text_personal_access_token_max_lifetime_setting_info) %></em></p> |
|
| 12 | ||
| 13 |
<p><%= setting_check_box :api_audit_logging_enabled %> |
|
| 14 |
<em class="info"><%= l(:text_api_audit_logging_info) %></em></p> |
|
| 9 | 15 |
</div> |
| 10 | 16 | |
| 11 | 17 |
<%= submit_tag l(:button_save) %> |
| config/application.rb | ||
|---|---|---|
| 65 | 65 |
config.encoding = "utf-8" |
| 66 | 66 | |
| 67 | 67 |
# Configure sensitive parameters which will be filtered from the log file. |
| 68 |
config.filter_parameters += [:password, :salt, :twofa_totp_key] |
|
| 68 |
# The exact-match regexp filters the API key passed as ?key=... without |
|
| 69 |
# over-filtering unrelated parameters such as "keywords". |
|
| 70 |
config.filter_parameters += [:password, :salt, :twofa_totp_key, /\Akey\z/] |
|
| 69 | 71 | |
| 70 | 72 |
config.action_mailer.perform_deliveries = false |
| 71 | 73 | |
| config/locales/en.yml | ||
|---|---|---|
| 1485 | 1485 |
reaction_text_x_other_users: |
| 1486 | 1486 |
one: "1 other" |
| 1487 | 1487 |
other: "%{count} others"
|
| 1488 |
label_personal_access_token: Personal access token |
|
| 1489 |
label_personal_access_token_plural: Personal access tokens |
|
| 1490 |
label_personal_access_token_new: New token |
|
| 1491 |
field_expires_on: Expires |
|
| 1492 |
label_last_used: Last used |
|
| 1493 |
label_token_expired: expired |
|
| 1494 |
label_legacy: (legacy) |
|
| 1495 |
button_revoke: Revoke |
|
| 1496 |
notice_personal_access_token_created: "Personal access token %{name} was created."
|
|
| 1497 |
text_personal_access_token_show_once: "Copy your new token now. For security reasons it is stored hashed and cannot be displayed again." |
|
| 1498 |
text_personal_access_tokens_info: "Personal access tokens can be used instead of your API access key to authenticate against the REST API. Each token has its own expiration date and can be revoked individually. The token value is only displayed once, at creation." |
|
| 1499 |
text_personal_access_token_name_info: "A short label so you can recognize this token later, e.g. \"CI pipeline\"." |
|
| 1500 |
text_personal_access_token_expiration_info: "Defaults to %{days} days."
|
|
| 1501 |
text_personal_access_token_max_lifetime_info: "Maximum lifetime allowed by the administrator: %{max} days."
|
|
| 1502 |
setting_personal_access_token_max_lifetime: Maximum lifetime of personal access tokens (days) |
|
| 1503 |
text_personal_access_token_max_lifetime_setting_info: "Upper bound for the expiration date of newly created personal access tokens, in days. 0 means no limit. Existing tokens are not affected." |
|
| 1504 |
field_scopes: Scopes |
|
| 1505 |
label_scopes_full_access: Full access |
|
| 1506 |
text_personal_access_token_scopes_info: "Optionally restrict this token to selected permissions. Leaving all boxes unchecked grants the token the full permissions of your account." |
|
| 1507 |
text_personal_access_token_admin_scope: Grants administrator permissions |
|
| 1508 |
setting_api_audit_logging_enabled: Enable API audit logging |
|
| 1509 |
text_api_audit_logging_info: "Writes one structured line per authenticated API request to log/api_audit.log (timestamp, user, credential, method, path, IP, response status)." |
|
| config/routes.rb | ||
|---|---|---|
| 99 | 99 |
get 'my/api_key', :to => 'my#show_api_key', :as => 'my_api_key' |
| 100 | 100 |
post 'my/api_key', :to => 'my#reset_api_key' |
| 101 | 101 |
post 'my/atom_key', :to => 'my#reset_atom_key', :as => 'my_atom_key' |
| 102 |
get 'my/personal_access_tokens', :to => 'personal_access_tokens#index', :as => 'my_personal_access_tokens' |
|
| 103 |
get 'my/personal_access_tokens/new', :to => 'personal_access_tokens#new', :as => 'new_my_personal_access_token' |
|
| 104 |
post 'my/personal_access_tokens', :to => 'personal_access_tokens#create' |
|
| 105 |
delete 'my/personal_access_tokens/:id', :to => 'personal_access_tokens#destroy', :as => 'my_personal_access_token' |
|
| 102 | 106 |
match 'my/password', :controller => 'my', :action => 'password', :via => [:get, :post] |
| 103 | 107 |
match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post |
| 104 | 108 |
match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post |
| config/settings.yml | ||
|---|---|---|
| 377 | 377 |
default: 0 |
| 378 | 378 |
reactions_enabled: |
| 379 | 379 |
default: 1 |
| 380 |
personal_access_token_max_lifetime: |
|
| 381 |
format: int |
|
| 382 |
default: 0 |
|
| 383 |
security_notifications: 1 |
|
| 384 |
api_audit_logging_enabled: |
|
| 385 |
default: 0 |
|
| 386 |
security_notifications: 1 |
|
| db/migrate/20260820120000_create_personal_access_tokens.rb | ||
|---|---|---|
| 1 |
class CreatePersonalAccessTokens < ActiveRecord::Migration[8.1] |
|
| 2 |
def change |
|
| 3 |
create_table :personal_access_tokens do |t| |
|
| 4 |
t.references :user, null: false |
|
| 5 |
t.string :name, limit: 255, null: false |
|
| 6 |
t.string :token_digest, limit: 64, null: false |
|
| 7 |
t.date :expires_on, null: false |
|
| 8 |
t.datetime :last_used_on |
|
| 9 |
t.text :scopes |
|
| 10 |
t.timestamps null: false |
|
| 11 |
end |
|
| 12 |
add_index :personal_access_tokens, :token_digest, unique: true |
|
| 13 |
add_index :personal_access_tokens, [:user_id, :name], unique: true |
|
| 14 |
end |
|
| 15 |
end |
|
| lib/redmine/api_audit.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
module Redmine |
|
| 21 |
# Structured audit log of authenticated REST API requests, one JSON |
|
| 22 |
# line per request, written to log/api_audit.log by default |
|
| 23 |
module ApiAudit |
|
| 24 |
mattr_accessor :logger |
|
| 25 | ||
| 26 |
def self.log(payload) |
|
| 27 |
self.logger ||= default_logger |
|
| 28 |
logger.info(payload.to_json) |
|
| 29 |
end |
|
| 30 | ||
| 31 |
def self.default_logger |
|
| 32 |
logger = Logger.new(Rails.root.join('log', 'api_audit.log'), 'weekly')
|
|
| 33 |
logger.formatter = proc {|_severity, _time, _progname, msg| "#{msg}\n"}
|
|
| 34 |
logger |
|
| 35 |
end |
|
| 36 |
end |
|
| 37 |
end |
|
| test/fixtures/personal_access_tokens.yml | ||
|---|---|---|
| 1 |
--- |
|
| 2 |
personal_access_tokens_001: |
|
| 3 |
id: 1 |
|
| 4 |
user_id: 2 |
|
| 5 |
name: CI pipeline |
|
| 6 |
token_digest: <%= Digest::SHA256.hexdigest("rmpat_1234567890abcdef1234567890abcdef12345678") %>
|
|
| 7 |
expires_on: 2050-01-01 |
|
| 8 |
last_used_on: |
|
| 9 |
created_at: 2026-08-01 10:00:00 |
|
| 10 |
updated_at: 2026-08-01 10:00:00 |
|
| 11 |
personal_access_tokens_002: |
|
| 12 |
id: 2 |
|
| 13 |
user_id: 2 |
|
| 14 |
name: Expired token |
|
| 15 |
token_digest: <%= Digest::SHA256.hexdigest("rmpat_expired67890abcdef1234567890abcdef123456") %>
|
|
| 16 |
expires_on: 2020-01-01 |
|
| 17 |
last_used_on: 2019-12-01 10:00:00 |
|
| 18 |
created_at: 2019-01-01 10:00:00 |
|
| 19 |
updated_at: 2019-01-01 10:00:00 |
|
| 20 |
personal_access_tokens_003: |
|
| 21 |
id: 3 |
|
| 22 |
user_id: 5 |
|
| 23 |
name: Locked user token |
|
| 24 |
token_digest: <%= Digest::SHA256.hexdigest("rmpat_locked7890abcdef1234567890abcdef12345678") %>
|
|
| 25 |
expires_on: 2050-01-01 |
|
| 26 |
last_used_on: |
|
| 27 |
created_at: 2026-08-01 10:00:00 |
|
| 28 |
updated_at: 2026-08-01 10:00:00 |
|
| test/functional/my_controller_test.rb | ||
|---|---|---|
| 891 | 891 |
assert_select 'pre', User.find(2).api_key |
| 892 | 892 |
end |
| 893 | 893 | |
| 894 |
def test_account_should_link_to_personal_access_tokens_when_rest_api_is_enabled |
|
| 895 |
with_settings :rest_api_enabled => '1' do |
|
| 896 |
get :account |
|
| 897 |
assert_response :success |
|
| 898 |
assert_select 'a[href="/my/personal_access_tokens"]', :minimum => 1 |
|
| 899 |
end |
|
| 900 |
end |
|
| 901 | ||
| 902 |
def test_account_should_not_link_to_personal_access_tokens_when_rest_api_is_disabled |
|
| 903 |
with_settings :rest_api_enabled => '0' do |
|
| 904 |
get :account |
|
| 905 |
assert_response :success |
|
| 906 |
assert_select 'a[href="/my/personal_access_tokens"]', :count => 0 |
|
| 907 |
end |
|
| 908 |
end |
|
| 909 | ||
| 894 | 910 |
def test_reset_api_key_with_existing_key |
| 895 | 911 |
@previous_token_value = User.find(2).api_key # Will generate one if it's missing |
| 896 | 912 |
post :reset_api_key |
| test/functional/personal_access_tokens_controller_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../test_helper' |
|
| 21 | ||
| 22 |
class PersonalAccessTokensControllerTest < Redmine::ControllerTest |
|
| 23 |
def setup |
|
| 24 |
@request.session[:user_id] = 2 |
|
| 25 |
end |
|
| 26 | ||
| 27 |
def test_index_should_list_only_own_tokens |
|
| 28 |
get :index |
|
| 29 |
assert_response :success |
|
| 30 |
assert_select 'table.list tbody' do |
|
| 31 |
assert_select 'tr', 2 |
|
| 32 |
assert_select 'td', :text => 'CI pipeline' |
|
| 33 |
assert_select 'td', :text => 'Locked user token', :count => 0 |
|
| 34 |
end |
|
| 35 |
end |
|
| 36 | ||
| 37 |
def test_index_should_require_login |
|
| 38 |
@request.session[:user_id] = nil |
|
| 39 |
get :index |
|
| 40 |
assert_response :redirect |
|
| 41 |
end |
|
| 42 | ||
| 43 |
def test_new_should_display_the_form |
|
| 44 |
get :new |
|
| 45 |
assert_response :success |
|
| 46 |
assert_select 'input[name=?]', 'personal_access_token[name]' |
|
| 47 |
assert_select 'input[name=?]', 'personal_access_token[expires_on]' |
|
| 48 |
end |
|
| 49 | ||
| 50 |
def test_new_should_display_scope_checkboxes |
|
| 51 |
get :new |
|
| 52 |
assert_response :success |
|
| 53 |
assert_select 'input[type=checkbox][name=?]', 'personal_access_token[scopes][]', :minimum => 10 |
|
| 54 |
assert_select 'input[type=checkbox][name=?][value=admin]', 'personal_access_token[scopes][]' |
|
| 55 |
end |
|
| 56 | ||
| 57 |
def test_index_should_show_a_scopes_summary |
|
| 58 |
PersonalAccessToken.find(1).update_column(:scopes, 'view_issues add_issues') |
|
| 59 |
get :index |
|
| 60 |
assert_response :success |
|
| 61 |
assert_select 'tr#personal-access-token-1 td.scopes', :text => '2' |
|
| 62 |
assert_select 'tr#personal-access-token-2 td.scopes', :text => 'Full access' |
|
| 63 |
end |
|
| 64 | ||
| 65 |
def test_create_with_scopes_should_store_them |
|
| 66 |
post :create, :params => {
|
|
| 67 |
:personal_access_token => {
|
|
| 68 |
:name => 'Scoped token', |
|
| 69 |
:expires_on => 30.days.from_now.to_date.to_s, |
|
| 70 |
:scopes => ['', 'view_issues'] |
|
| 71 |
} |
|
| 72 |
} |
|
| 73 |
assert_redirected_to '/my/personal_access_tokens' |
|
| 74 |
assert_includes PersonalAccessToken.order(:id => :desc).first.scope_list, :view_issues |
|
| 75 |
end |
|
| 76 | ||
| 77 |
def test_new_should_clamp_the_default_expiration_to_the_max_lifetime |
|
| 78 |
with_settings :personal_access_token_max_lifetime => '7' do |
|
| 79 |
get :new |
|
| 80 |
assert_response :success |
|
| 81 |
assert_select 'input[name=?][value=?]', 'personal_access_token[expires_on]', |
|
| 82 |
7.days.from_now.to_date.to_s |
|
| 83 |
end |
|
| 84 |
end |
|
| 85 | ||
| 86 |
def test_create_should_add_a_token_and_show_its_value_once |
|
| 87 |
assert_difference 'PersonalAccessToken.count', 1 do |
|
| 88 |
post :create, :params => {
|
|
| 89 |
:personal_access_token => {
|
|
| 90 |
:name => 'Deploy script', |
|
| 91 |
:expires_on => 30.days.from_now.to_date.to_s |
|
| 92 |
} |
|
| 93 |
} |
|
| 94 |
end |
|
| 95 |
assert_redirected_to '/my/personal_access_tokens' |
|
| 96 |
token = PersonalAccessToken.order(:id => :desc).first |
|
| 97 |
assert_equal users(:users_002), token.user |
|
| 98 | ||
| 99 |
follow_redirect_and_assert_token_displayed |
|
| 100 |
end |
|
| 101 | ||
| 102 |
def test_create_with_invalid_params_should_redisplay_the_form |
|
| 103 |
assert_no_difference 'PersonalAccessToken.count' do |
|
| 104 |
post :create, :params => {
|
|
| 105 |
:personal_access_token => {:name => '', :expires_on => 30.days.from_now.to_date.to_s}
|
|
| 106 |
} |
|
| 107 |
end |
|
| 108 |
assert_response :success |
|
| 109 |
assert_select_error /Name/ |
|
| 110 |
end |
|
| 111 | ||
| 112 |
def test_destroy_should_remove_own_token |
|
| 113 |
assert_difference 'PersonalAccessToken.count', -1 do |
|
| 114 |
delete :destroy, :params => {:id => 1}
|
|
| 115 |
end |
|
| 116 |
assert_redirected_to '/my/personal_access_tokens' |
|
| 117 |
end |
|
| 118 | ||
| 119 |
def test_destroy_should_not_remove_another_users_token |
|
| 120 |
assert_no_difference 'PersonalAccessToken.count' do |
|
| 121 |
delete :destroy, :params => {:id => 3}
|
|
| 122 |
end |
|
| 123 |
assert_response :not_found |
|
| 124 |
end |
|
| 125 | ||
| 126 |
private |
|
| 127 | ||
| 128 |
def follow_redirect_and_assert_token_displayed |
|
| 129 |
# The plaintext value is passed through the flash and displayed once |
|
| 130 |
get :index |
|
| 131 |
assert_response :success |
|
| 132 |
assert_select 'pre.personal-access-token-value', :text => /\Armpat_[0-9a-f]{40}\z/
|
|
| 133 |
# A second render must not display it again |
|
| 134 |
get :index |
|
| 135 |
assert_select 'pre.personal-access-token-value', :count => 0 |
|
| 136 |
end |
|
| 137 |
end |
|
| test/functional/settings_controller_test.rb | ||
|---|---|---|
| 80 | 80 |
assert_equal 'Test footer', Setting.emails_footer |
| 81 | 81 |
end |
| 82 | 82 | |
| 83 |
def test_edit_integrations_tab_should_include_personal_access_token_max_lifetime |
|
| 84 |
get :edit, :params => {:tab => 'integrations'}
|
|
| 85 |
assert_response :success |
|
| 86 |
assert_select 'input[name=?]', 'settings[personal_access_token_max_lifetime]' |
|
| 87 |
assert_select 'input[name=?]', 'settings[api_audit_logging_enabled]' |
|
| 88 |
end |
|
| 89 | ||
| 83 | 90 |
def test_edit_commit_update_keywords |
| 84 | 91 |
with_settings :commit_update_keywords => [ |
| 85 | 92 |
{"keywords" => "fixes, resolves", "status_id" => "3"},
|
| test/integration/api_test/api_audit_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../../test_helper' |
|
| 21 | ||
| 22 |
class Redmine::ApiTest::ApiAuditTest < Redmine::ApiTest::Base |
|
| 23 |
VALID_PLAINTEXT = "rmpat_1234567890abcdef1234567890abcdef12345678" |
|
| 24 | ||
| 25 |
def setup |
|
| 26 |
super |
|
| 27 |
@io = StringIO.new |
|
| 28 |
@previous_logger = Redmine::ApiAudit.logger |
|
| 29 |
Redmine::ApiAudit.logger = Logger.new(@io) |
|
| 30 |
Redmine::ApiAudit.logger.formatter = proc {|_severity, _time, _progname, msg| "#{msg}\n"}
|
|
| 31 |
end |
|
| 32 | ||
| 33 |
def teardown |
|
| 34 |
super |
|
| 35 |
Redmine::ApiAudit.logger = @previous_logger |
|
| 36 |
end |
|
| 37 | ||
| 38 |
test "should log a personal access token request when enabled" do |
|
| 39 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 40 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 41 |
end |
|
| 42 |
assert_response :success |
|
| 43 | ||
| 44 |
entry = last_entry |
|
| 45 |
assert_equal 'pat:1', entry['credential'] |
|
| 46 |
assert_equal 2, entry['user_id'] |
|
| 47 |
assert_equal 'jsmith', entry['user'] |
|
| 48 |
assert_equal 'GET', entry['method'] |
|
| 49 |
assert_equal '/users/current.json', entry['path'] |
|
| 50 |
assert_equal 200, entry['status'] |
|
| 51 |
assert entry['at'].present? |
|
| 52 |
assert entry['ip'].present? |
|
| 53 |
end |
|
| 54 | ||
| 55 |
test "should log a legacy api key request when enabled" do |
|
| 56 |
token = Token.create!(:user => users(:users_002), :action => 'api') |
|
| 57 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 58 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => token.value}
|
|
| 59 |
end |
|
| 60 |
assert_response :success |
|
| 61 |
assert_equal 'api_key', last_entry['credential'] |
|
| 62 |
end |
|
| 63 | ||
| 64 |
test "should log denied requests" do |
|
| 65 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 66 |
user: User.find(1), name: 'Scoped audit token', |
|
| 67 |
expires_on: 30.days.from_now.to_date, scopes: 'view_issues' |
|
| 68 |
) |
|
| 69 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 70 |
get '/users.json', :headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 71 |
end |
|
| 72 |
assert_response :forbidden |
|
| 73 |
assert_equal 403, last_entry['status'] |
|
| 74 |
end |
|
| 75 | ||
| 76 |
test "should not log anything when disabled" do |
|
| 77 |
with_settings :api_audit_logging_enabled => '0' do |
|
| 78 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 79 |
end |
|
| 80 |
assert_response :success |
|
| 81 |
assert_equal '', @io.string |
|
| 82 |
end |
|
| 83 | ||
| 84 |
test "should log status 500 when the action raises" do |
|
| 85 |
UsersController.any_instance.stubs(:show).raises(StandardError.new('boom'))
|
|
| 86 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 87 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 88 |
end |
|
| 89 |
assert_response :internal_server_error |
|
| 90 |
assert_equal 500, last_entry['status'] |
|
| 91 |
end |
|
| 92 | ||
| 93 |
test "an audit logging failure should not break the API response" do |
|
| 94 |
Redmine::ApiAudit.stubs(:log).raises(Errno::ENOSPC.new('disk full'))
|
|
| 95 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 96 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 97 |
end |
|
| 98 |
assert_response :success |
|
| 99 |
end |
|
| 100 | ||
| 101 |
test "should never log the token value" do |
|
| 102 |
with_settings :api_audit_logging_enabled => '1' do |
|
| 103 |
get "/users/current.json?key=#{VALID_PLAINTEXT}"
|
|
| 104 |
end |
|
| 105 |
assert_response :success |
|
| 106 |
assert_not_includes @io.string, VALID_PLAINTEXT |
|
| 107 |
assert_equal '/users/current.json', last_entry['path'] |
|
| 108 |
end |
|
| 109 | ||
| 110 |
private |
|
| 111 | ||
| 112 |
def last_entry |
|
| 113 |
ActiveSupport::JSON.decode(@io.string.lines.last) |
|
| 114 |
end |
|
| 115 |
end |
|
| test/integration/api_test/personal_access_token_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../../test_helper' |
|
| 21 | ||
| 22 |
class Redmine::ApiTest::PersonalAccessTokenTest < Redmine::ApiTest::Base |
|
| 23 |
VALID_PLAINTEXT = "rmpat_1234567890abcdef1234567890abcdef12345678" |
|
| 24 |
EXPIRED_PLAINTEXT = "rmpat_expired67890abcdef1234567890abcdef123456" |
|
| 25 | ||
| 26 |
test "should authenticate with a personal access token in the X-Redmine-API-Key header" do |
|
| 27 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 28 |
assert_response :success |
|
| 29 |
assert_equal users(:users_002).login, ActiveSupport::JSON.decode(response.body)['user']['login'] |
|
| 30 |
end |
|
| 31 | ||
| 32 |
test "should authenticate with a personal access token as the key parameter" do |
|
| 33 |
get "/users/current.json?key=#{VALID_PLAINTEXT}"
|
|
| 34 |
assert_response :success |
|
| 35 |
assert_equal users(:users_002).login, ActiveSupport::JSON.decode(response.body)['user']['login'] |
|
| 36 |
end |
|
| 37 | ||
| 38 |
test "should authenticate with a personal access token as HTTP Basic username" do |
|
| 39 |
get '/users/current.json', :headers => credentials(VALID_PLAINTEXT, 'X') |
|
| 40 |
assert_response :success |
|
| 41 |
assert_equal users(:users_002).login, ActiveSupport::JSON.decode(response.body)['user']['login'] |
|
| 42 |
end |
|
| 43 | ||
| 44 |
test "should deny an expired personal access token" do |
|
| 45 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => EXPIRED_PLAINTEXT}
|
|
| 46 |
assert_response :unauthorized |
|
| 47 |
end |
|
| 48 | ||
| 49 |
test "should deny a revoked personal access token" do |
|
| 50 |
PersonalAccessToken.find(1).destroy |
|
| 51 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 52 |
assert_response :unauthorized |
|
| 53 |
end |
|
| 54 | ||
| 55 |
test "should still authenticate with a legacy api key" do |
|
| 56 |
user = users(:users_002) |
|
| 57 |
token = Token.create!(:user => user, :action => 'api') |
|
| 58 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => token.value}
|
|
| 59 |
assert_response :success |
|
| 60 |
assert_equal user.login, ActiveSupport::JSON.decode(response.body)['user']['login'] |
|
| 61 |
end |
|
| 62 | ||
| 63 |
test "scoped token of an admin should not grant admin-only endpoints" do |
|
| 64 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 65 |
user: User.find(1), name: 'Scoped admin token', |
|
| 66 |
expires_on: 30.days.from_now.to_date, scopes: 'view_issues' |
|
| 67 |
) |
|
| 68 |
get '/users.json', :headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 69 |
assert_response :forbidden |
|
| 70 |
end |
|
| 71 | ||
| 72 |
test "unscoped token of an admin should grant admin-only endpoints" do |
|
| 73 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 74 |
user: User.find(1), name: 'Unscoped admin token', |
|
| 75 |
expires_on: 30.days.from_now.to_date |
|
| 76 |
) |
|
| 77 |
get '/users.json', :headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 78 |
assert_response :success |
|
| 79 |
end |
|
| 80 | ||
| 81 |
test "scoped token should grant endpoints covered by its scopes" do |
|
| 82 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 83 |
user: users(:users_002), name: 'Issues token', |
|
| 84 |
expires_on: 30.days.from_now.to_date, scopes: 'view_issues' |
|
| 85 |
) |
|
| 86 |
get '/issues.json', :headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 87 |
assert_response :success |
|
| 88 |
end |
|
| 89 | ||
| 90 |
test "scoped token should deny endpoints outside its scopes" do |
|
| 91 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 92 |
user: users(:users_002), name: 'Issues only token', |
|
| 93 |
expires_on: 30.days.from_now.to_date, scopes: 'view_issues' |
|
| 94 |
) |
|
| 95 |
get '/time_entries.json', :headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 96 |
assert_response :forbidden |
|
| 97 |
end |
|
| 98 | ||
| 99 |
# Known limitation inherited from the OAuth scope mechanism (upstream |
|
| 100 |
# defect https://www.redmine.org/issues/44271): Issue#attributes_editable? |
|
| 101 |
# authorizes through the private Issue#user_tracker_permission?, which |
|
| 102 |
# selects roles directly and never calls User#allowed_to?, so the scope |
|
| 103 |
# intersection is bypassed for issue attribute mutations. A token scoped |
|
| 104 |
# to view_issues + add_issue_notes (a typical comment-bot token) can |
|
| 105 |
# therefore still edit issue attributes when the user's role allows it, |
|
| 106 |
# because add_issue_notes maps to issues#update and passes the controller |
|
| 107 |
# authorize. This test pins the current behavior and will start failing |
|
| 108 |
# once #44271 is fixed upstream - update it then. |
|
| 109 |
test "KNOWN LIMITATION (#44271): add_issue_notes scope does not prevent issue attribute edits" do |
|
| 110 |
_token, plaintext = PersonalAccessToken.generate!( |
|
| 111 |
user: users(:users_002), name: 'Notes-only token', |
|
| 112 |
expires_on: 30.days.from_now.to_date, scopes: 'view_issues add_issue_notes' |
|
| 113 |
) |
|
| 114 |
put '/issues/1.json', |
|
| 115 |
:params => {:issue => {:subject => 'Changed through a notes-only token'}},
|
|
| 116 |
:headers => {'X-Redmine-API-Key' => plaintext}
|
|
| 117 |
assert_response :no_content |
|
| 118 |
assert_equal 'Changed through a notes-only token', Issue.find(1).subject |
|
| 119 |
end |
|
| 120 | ||
| 121 |
test "should deny personal access tokens when the REST API is disabled" do |
|
| 122 |
with_settings :rest_api_enabled => '0', :login_required => '1' do |
|
| 123 |
get '/users/current.json', :headers => {'X-Redmine-API-Key' => VALID_PLAINTEXT}
|
|
| 124 |
assert_response :forbidden |
|
| 125 |
end |
|
| 126 |
end |
|
| 127 |
end |
|
| test/unit/lib/parameter_filtering_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../../test_helper' |
|
| 21 | ||
| 22 |
class ParameterFilteringTest < ActiveSupport::TestCase |
|
| 23 |
def filter(params) |
|
| 24 |
ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters).filter(params) |
|
| 25 |
end |
|
| 26 | ||
| 27 |
test "the key parameter should be filtered from logs" do |
|
| 28 |
filtered = filter('key' => 'rmpat_1234567890abcdef1234567890abcdef12345678')
|
|
| 29 |
assert_equal '[FILTERED]', filtered['key'] |
|
| 30 |
end |
|
| 31 | ||
| 32 |
test "passwords should be filtered from logs" do |
|
| 33 |
assert_equal '[FILTERED]', filter('password' => 'secret')['password']
|
|
| 34 |
assert_equal '[FILTERED]', filter('sudo_password' => 'secret')['sudo_password']
|
|
| 35 |
end |
|
| 36 | ||
| 37 |
test "parameters merely containing key should not be over-filtered" do |
|
| 38 |
assert_equal 'fixes', filter('keywords' => 'fixes')['keywords']
|
|
| 39 |
end |
|
| 40 |
end |
|
| test/unit/personal_access_token_test.rb | ||
|---|---|---|
| 1 |
# frozen_string_literal: true |
|
| 2 | ||
| 3 |
# Redmine - project management software |
|
| 4 |
# Copyright (C) 2006- Jean-Philippe Lang |
|
| 5 |
# |
|
| 6 |
# This program is free software; you can redistribute it and/or |
|
| 7 |
# modify it under the terms of the GNU General Public License |
|
| 8 |
# as published by the Free Software Foundation; either version 2 |
|
| 9 |
# of the License, or (at your option) any later version. |
|
| 10 |
# |
|
| 11 |
# This program is distributed in the hope that it will be useful, |
|
| 12 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
| 13 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
| 14 |
# GNU General Public License for more details. |
|
| 15 |
# |
|
| 16 |
# You should have received a copy of the GNU General Public License |
|
| 17 |
# along with this program; if not, write to the Free Software |
|
| 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
| 19 | ||
| 20 |
require_relative '../test_helper' |
|
| 21 | ||
| 22 |
class PersonalAccessTokenTest < ActiveSupport::TestCase |
|
| 23 |
VALID_PLAINTEXT = "rmpat_1234567890abcdef1234567890abcdef12345678" |
|
| 24 |
EXPIRED_PLAINTEXT = "rmpat_expired67890abcdef1234567890abcdef123456" |
|
| 25 |
LOCKED_USER_PLAINTEXT = "rmpat_locked7890abcdef1234567890abcdef12345678" |
|
| 26 | ||
| 27 |
test "generate! should return the record and a prefixed plaintext value" do |
|
| 28 |
token, plaintext = PersonalAccessToken.generate!( |
|
| 29 |
user: users(:users_002), name: 'Deploy script', expires_on: 30.days.from_now.to_date |
|
| 30 |
) |
|
| 31 |
assert token.persisted? |
|
| 32 |
assert plaintext.start_with?('rmpat_')
|
|
| 33 |
assert_equal 46, plaintext.length |
|
| 34 |
end |
|
| 35 | ||
| 36 |
test "generate! should store only the SHA256 digest of the value" do |
|
| 37 |
token, plaintext = PersonalAccessToken.generate!( |
|
| 38 |
user: users(:users_002), name: 'Deploy script', expires_on: 30.days.from_now.to_date |
|
| 39 |
) |
|
| 40 |
assert_equal Digest::SHA256.hexdigest(plaintext), token.token_digest |
|
| 41 |
assert_not token.attributes.value?(plaintext) |
|
| 42 |
end |
|
| 43 | ||
| 44 |
test "should require a name" do |
|
| 45 |
token = PersonalAccessToken.new( |
|
| 46 |
user: users(:users_002), name: '', expires_on: 30.days.from_now.to_date |
|
| 47 |
) |
|
| 48 |
assert_not token.valid? |
|
| 49 |
assert token.errors[:name].present? |
|
| 50 |
end |
|
| 51 | ||
| 52 |
test "should require a unique name per user" do |
|
| 53 |
duplicate = PersonalAccessToken.new( |
|
| 54 |
user: users(:users_002), name: 'CI pipeline', expires_on: 30.days.from_now.to_date |
|
| 55 |
) |
|
| 56 |
assert_not duplicate.valid? |
|
| 57 |
assert duplicate.errors[:name].present? |
|
| 58 | ||
| 59 |
other_user = PersonalAccessToken.new( |
|
| 60 |
user: users(:users_003), name: 'CI pipeline', expires_on: 30.days.from_now.to_date |
|
| 61 |
) |
|
| 62 |
assert other_user.valid? |
|
| 63 |
end |
|
| 64 | ||
| 65 |
test "should require an expiration date" do |
|
| 66 |
token = PersonalAccessToken.new(user: users(:users_002), name: 'No expiry') |
|
| 67 |
assert_not token.valid? |
|
| 68 |
assert token.errors[:expires_on].present? |
|
| 69 |
end |
|
| 70 | ||
| 71 |
test "should reject an expiration date in the past" do |
|
| 72 |
token = PersonalAccessToken.new( |
|
| 73 |
user: users(:users_002), name: 'Past expiry', expires_on: Date.yesterday |
|
| 74 |
) |
|
| 75 |
assert_not token.valid? |
|
| 76 |
assert token.errors[:expires_on].present? |
|
| 77 |
end |
|
| 78 | ||
| 79 |
test "should enforce the admin max lifetime setting" do |
|
| 80 |
with_settings :personal_access_token_max_lifetime => '30' do |
|
| 81 |
too_far = PersonalAccessToken.new( |
|
| 82 |
user: users(:users_002), name: 'Too far', expires_on: 31.days.from_now.to_date |
|
| 83 |
) |
|
| 84 |
assert_not too_far.valid? |
|
| 85 |
assert too_far.errors[:expires_on].present? |
|
| 86 | ||
| 87 |
at_limit = PersonalAccessToken.new( |
|
| 88 |
user: users(:users_002), name: 'At limit', expires_on: 30.days.from_now.to_date |
|
| 89 |
) |
|
| 90 |
assert at_limit.valid? |
|
| 91 |
end |
|
| 92 |
end |
|
| 93 | ||
| 94 |
test "max lifetime setting should only accept integers" do |
|
| 95 |
setting = Setting.new(:name => 'personal_access_token_max_lifetime', :value => 'abc') |
|
| 96 |
assert_not setting.valid? |
|
| 97 |
setting.value = '30' |
|
| 98 |
assert setting.valid? |
|
| 99 |
end |
|
| 100 | ||
| 101 |
test "should not limit lifetime when the setting is zero" do |
|
| 102 |
with_settings :personal_access_token_max_lifetime => '0' do |
|
| 103 |
token = PersonalAccessToken.new( |
|
| 104 |
user: users(:users_002), name: 'Far future', expires_on: 10.years.from_now.to_date |
|
| 105 |
) |
|
| 106 |
assert token.valid? |
|
| 107 |
end |
|
| 108 |
end |
|
| 109 | ||
| 110 |
test "expired? should be true only after the expiration date" do |
|
| 111 |
assert personal_access_tokens(:personal_access_tokens_002).expired? |
|
| 112 |
assert_not personal_access_tokens(:personal_access_tokens_001).expired? |
|
| 113 |
end |
|
| 114 | ||
| 115 |
test "find_active_user should return the owner for a valid token" do |
|
| 116 |
assert_equal users(:users_002), PersonalAccessToken.find_active_user(VALID_PLAINTEXT) |
|
| 117 |
end |
|
| 118 | ||
| 119 |
test "find_active_user should return nil for an expired token" do |
|
| 120 |
assert_nil PersonalAccessToken.find_active_user(EXPIRED_PLAINTEXT) |
|
| 121 |
end |
|
| 122 | ||
| 123 |
test "find_active_user should return nil for a locked user" do |
|
| 124 |
assert_nil PersonalAccessToken.find_active_user(LOCKED_USER_PLAINTEXT) |
|
| 125 |
end |
|
| 126 | ||
| 127 |
test "find_active_user should return nil for unknown or blank keys" do |
|
| 128 |
assert_nil PersonalAccessToken.find_active_user('rmpat_unknown')
|
|
| 129 |
assert_nil PersonalAccessToken.find_active_user('')
|
|
| 130 |
assert_nil PersonalAccessToken.find_active_user(nil) |
|
| 131 |
end |
|
| 132 | ||
| 133 |
test "find_active_user should record last_used_on" do |
|
| 134 |
token = personal_access_tokens(:personal_access_tokens_001) |
|
| 135 |
assert_nil token.last_used_on |
|
| 136 |
PersonalAccessToken.find_active_user(VALID_PLAINTEXT) |
|
| 137 |
assert_not_nil token.reload.last_used_on |
|
| 138 |
end |
|
| 139 | ||
| 140 |
test "scope_list should return scopes as symbols" do |
|
| 141 |
token = PersonalAccessToken.new(:scopes => 'view_issues add_issues') |
|
| 142 |
assert_equal [:view_issues, :add_issues], token.scope_list |
|
| 143 |
assert_equal [], PersonalAccessToken.new(:scopes => nil).scope_list |
|
| 144 |
end |
|
| 145 | ||
| 146 |
test "scopes should accept an array and normalize it" do |
|
| 147 |
token = PersonalAccessToken.new(:scopes => ['', 'view_issues', 'admin']) |
|
| 148 |
assert_equal 'view_issues admin', token.scopes |
|
| 149 |
end |
|
| 150 | ||
| 151 |
test "should reject unknown scope names" do |
|
| 152 |
token = PersonalAccessToken.new( |
|
| 153 |
:user => users(:users_002), :name => 'Bad scope', |
|
| 154 |
:expires_on => 30.days.from_now.to_date, :scopes => 'view_issues not_a_permission' |
|
| 155 |
) |
|
| 156 |
assert_not token.valid? |
|
| 157 |
assert token.errors[:scopes].present? |
|
| 158 |
end |
|
| 159 | ||
| 160 |
test "blank scopes should be valid and mean full access" do |
|
| 161 |
token = PersonalAccessToken.new( |
|
| 162 |
:user => users(:users_002), :name => 'Full access', |
|
| 163 |
:expires_on => 30.days.from_now.to_date, :scopes => '' |
|
| 164 |
) |
|
| 165 |
assert token.valid? |
|
| 166 |
assert_equal [], token.scope_list |
|
| 167 |
end |
|
| 168 | ||
| 169 |
test "saving a scoped token should force-include public permissions" do |
|
| 170 |
token = PersonalAccessToken.create!( |
|
| 171 |
:user => users(:users_002), :name => 'Scoped', |
|
| 172 |
:expires_on => 30.days.from_now.to_date, :scopes => 'view_issues' |
|
| 173 |
) |
|
| 174 |
Redmine::AccessControl.public_permissions.map(&:name).each do |public_permission| |
|
| 175 |
assert_includes token.scope_list, public_permission |
|
| 176 |
end |
|
| 177 |
assert_includes token.scope_list, :view_issues |
|
| 178 |
end |
|
| 179 | ||
| 180 |
test "find_active should return the token for a valid value" do |
|
| 181 |
assert_equal personal_access_tokens(:personal_access_tokens_001), |
|
| 182 |
PersonalAccessToken.find_active(VALID_PLAINTEXT) |
|
| 183 |
assert_nil PersonalAccessToken.find_active(EXPIRED_PLAINTEXT) |
|
| 184 |
assert_nil PersonalAccessToken.find_active(LOCKED_USER_PLAINTEXT) |
|
| 185 |
end |
|
| 186 | ||
| 187 |
test "import_legacy_api_tokens! should convert legacy api keys to hashed tokens" do |
|
| 188 |
user = users(:users_003) |
|
| 189 |
legacy = Token.create!(:user => user, :action => 'api') |
|
| 190 |
legacy_value = legacy.value |
|
| 191 | ||
| 192 |
assert_difference 'PersonalAccessToken.count', 1 do |
|
| 193 |
assert_difference 'Token.where(:action => "api").count', -1 do |
|
| 194 |
PersonalAccessToken.import_legacy_api_tokens! |
|
| 195 |
end |
|
| 196 |
end |
|
| 197 | ||
| 198 |
imported = PersonalAccessToken.order(:id => :desc).first |
|
| 199 |
assert_equal user, imported.user |
|
| 200 |
assert_equal Digest::SHA256.hexdigest(legacy_value), imported.token_digest |
|
| 201 |
assert_equal 365.days.from_now.to_date, imported.expires_on |
|
| 202 |
# The same key keeps authenticating after the import |
|
| 203 |
assert_equal user, PersonalAccessToken.find_active_user(legacy_value) |
|
| 204 |
end |
|
| 205 | ||
| 206 |
test "import_legacy_api_tokens! should be idempotent" do |
|
| 207 |
Token.create!(:user => users(:users_003), :action => 'api') |
|
| 208 |
PersonalAccessToken.import_legacy_api_tokens! |
|
| 209 |
assert_no_difference 'PersonalAccessToken.count' do |
|
| 210 |
PersonalAccessToken.import_legacy_api_tokens! |
|
| 211 |
end |
|
| 212 |
end |
|
| 213 | ||
| 214 |
test "import_legacy_api_tokens! should honor the max lifetime setting" do |
|
| 215 |
Token.create!(:user => users(:users_003), :action => 'api') |
|
| 216 |
with_settings :personal_access_token_max_lifetime => '30' do |
|
| 217 |
PersonalAccessToken.import_legacy_api_tokens! |
|
| 218 |
end |
|
| 219 |
assert_equal 30.days.from_now.to_date, PersonalAccessToken.order(:id => :desc).first.expires_on |
|
| 220 |
end |
|
| 221 | ||
| 222 |
test "find_active_user should throttle last_used_on updates" do |
|
| 223 |
token = personal_access_tokens(:personal_access_tokens_001) |
|
| 224 |
recent = 5.minutes.ago |
|
| 225 |
token.update_column(:last_used_on, recent) |
|
| 226 |
PersonalAccessToken.find_active_user(VALID_PLAINTEXT) |
|
| 227 |
assert_equal recent.to_i, token.reload.last_used_on.to_i |
|
| 228 | ||
| 229 |
stale = 2.hours.ago |
|
| 230 |
token.update_column(:last_used_on, stale) |
|
| 231 |
PersonalAccessToken.find_active_user(VALID_PLAINTEXT) |
|
| 232 |
assert_operator token.reload.last_used_on, :>, 1.minute.ago |
|
| 233 |
end |
|
| 234 |
end |
|
- « Previous
- 1
- 2
- 3
- Next »