Project

General

Profile

Feature #43881 » personal-access-tokens-43881.patch

Bogdan Egikov, 2026-08-20 13:58

View differences:

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
(3-3/3)