Merge pull request #6006 from opf/housekeeping/hashed-extended-token

Implement HashedToken (ExtendedToken from MOTP) in Core

[ci skip]
pull/6011/head
Oliver Günther 7 years ago committed by GitHub
commit 64115eb2e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      app/assets/stylesheets/content/_notifications.sass
  2. 29
      app/controllers/account_controller.rb
  3. 2
      app/controllers/concerns/omniauth_login.rb
  4. 16
      app/controllers/concerns/user_invitation.rb
  5. 48
      app/controllers/my_controller.rb
  6. 36
      app/models/token/api.rb
  7. 36
      app/models/token/auto_login.rb
  8. 64
      app/models/token/base.rb
  9. 57
      app/models/token/expirable_token.rb
  10. 64
      app/models/token/hashed_token.rb
  11. 43
      app/models/token/invitation.rb
  12. 41
      app/models/token/recovery.rb
  13. 45
      app/models/token/rss.rb
  14. 50
      app/models/user.rb
  15. 20
      app/views/my/access_token.html.erb
  16. 2
      app/workers/deliver_invitation_job.rb
  17. 7
      config/locales/en.yml
  18. 2
      config/routes.rb
  19. 48
      db/migrate/20171106074835_move_hashed_token_to_core.rb
  20. 2
      features/step_definitions/password_steps.rb
  21. 7
      spec/controllers/account_controller_spec.rb
  22. 4
      spec/controllers/api/v2/authentication_spec.rb
  23. 4
      spec/controllers/concerns/user_invitation_spec.rb
  24. 68
      spec/controllers/my_controller_spec.rb
  25. 2
      spec/controllers/users_controller_spec.rb
  26. 20
      spec/factories/token_factory.rb
  27. 18
      spec/features/users/my_spec.rb
  28. 13
      spec/features/users/resend_invitation_spec.rb
  29. 27
      spec/models/token/base_token_spec.rb
  30. 67
      spec/models/token/hashed_token_spec.rb
  31. 6
      spec/models/user_deletion_spec.rb
  32. 12
      spec/requests/api/v3/authentication_spec.rb
  33. 2
      spec/requests/auth/api_v2_spec.rb
  34. 8
      spec/routing/my_spec.rb
  35. 4
      spec_legacy/fixtures/tokens.yml
  36. 68
      spec_legacy/functional/my_controller_spec.rb
  37. 4
      spec_legacy/functional/user_mailer_spec.rb
  38. 8
      spec_legacy/integration/api_spec/disabled_rest_api_spec.rb
  39. 18
      spec_legacy/support/legacy_assertions.rb
  40. 29
      spec_legacy/unit/user_spec.rb

@ -306,6 +306,7 @@ $nm-upload-box-padding: rem-calc(15) rem-calc(25)
float: none
position: absolute
right: rem-calc(11)
top: rem-calc(11)
cursor: pointer
&::before

@ -72,7 +72,7 @@ class AccountController < ApplicationController
return redirect_to(home_url) unless allow_lost_password_recovery?
if params[:token]
@token = Token.find_by(action: 'recovery', value: params[:token].to_s)
@token = ::Token::Recovery.find_by_plaintext_value(params[:token])
redirect_to(home_url) && return unless @token and !@token.expired?
@user = @token.user
if request.post?
@ -110,7 +110,7 @@ class AccountController < ApplicationController
end
# create a new token for password recovery
token = Token.new(user_id: user.id, action: 'recovery')
token = Token::Recovery.new(user_id: user.id)
if token.save
UserMailer.password_lost(token).deliver_now
flash[:notice] = l(:notice_account_lost_email_sent)
@ -154,12 +154,21 @@ class AccountController < ApplicationController
# Token based account activation
def activate
token = Token.find_by value: params[:token].to_s
token = ::Token::Invitation.find_by_plaintext_value(params[:token])
if token && token.action == 'register' && Setting.self_registration?
if token.nil? || token.expired?
flash[:error] = I18n.t(:notice_account_invalid_token)
redirect_to home_url
return
end
if token.user.invited?
activate_by_invite_token token
elsif Setting.self_registration?
activate_self_registered token
else
activate_by_invite_token token
flash[:error] = I18n.t(:notice_account_invalid_token)
redirect_to home_url
end
end
@ -361,7 +370,7 @@ class AccountController < ApplicationController
def logout_user
if User.current.logged?
cookies.delete OpenProject::Configuration['autologin_cookie_name']
Token.where(user_id: User.current.id, action: 'autologin').delete_all
Token::AutoLogin.where(user_id: current_user.id).delete_all
self.logged_user = nil
end
end
@ -439,9 +448,9 @@ class AccountController < ApplicationController
end
def set_autologin_cookie(user)
token = Token.create(user: user, action: 'autologin')
token = Token::AutoLogin.create(user: user)
cookie_options = {
value: token.value,
value: token.plain_value,
expires: 1.year.from_now,
path: OpenProject::Configuration['autologin_cookie_path'],
secure: OpenProject::Configuration['autologin_cookie_secure'],
@ -526,7 +535,7 @@ class AccountController < ApplicationController
#
# Pass a block for behavior when a user fails to save
def register_by_email_activation(user, _opts = {})
token = Token.new(user: user, action: 'register')
token = Token::Invitation.new(user: user)
if user.save and token.save
UserMailer.user_signed_up(token).deliver_now
flash[:notice] = l(:notice_account_register_done)
@ -654,7 +663,7 @@ class AccountController < ApplicationController
def invited_user
if session.include? :invitation_token
token = Token.find_by(value: session[:invitation_token])
token = Token::Invitation.find_by_plaintext_value session[:invitation_token]
token.user
end

@ -56,7 +56,7 @@ module Concerns::OmniauthLogin
user =
if session.include? :invitation_token
tok = Token.find_by value: session[:invitation_token]
tok = Token::Invitation.find_by value: session[:invitation_token]
u = tok.user
u.identity_url = identity_url_from_omniauth(auth_hash)
tok.destroy

@ -89,13 +89,13 @@ module UserInvitation
def reinvite_user(user_id)
clear_tokens user_id
Token.create(user_id: user_id, action: token_action).tap do |token|
Token::Invitation.create!(user_id: user_id).tap do |token|
OpenProject::Notifications.send Events.user_reinvited, token
end
end
def clear_tokens(user_id)
Token.where(user_id: user_id, action: token_action).destroy_all
Token::Invitation.where(user_id: user_id).delete_all
end
##
@ -146,9 +146,7 @@ module UserInvitation
user.invite
if user.valid?
token = invitation_token user
token.save!
token = Token::Invitation.create! user: user
user.save!
return [user, token]
@ -157,12 +155,4 @@ module UserInvitation
[user, nil]
end
def token_action
'invite'
end
def invitation_token(user)
Token.find_or_initialize_by user: user, action: token_action
end
end

@ -128,44 +128,36 @@ class MyController < ApplicationController
end
# Create a new feeds key
def reset_rss_key
if request.post?
if User.current.rss_token
User.current.rss_token.destroy
User.current.reload
end
User.current.rss_key
flash[:notice] = l(:notice_feeds_access_key_reseted)
end
redirect_to action: 'access_token'
end
def generate_rss_key
if request.post?
User.current.rss_key
flash[:notice] = l(:notice_feeds_access_key_generated)
token = Token::Rss.create!(user: current_user)
flash[:notice] = [
t('my.access_token.notice_reset_token', type: 'RSS'),
"<strong>#{token.plain_value}</strong>".html_safe,
t('my.access_token.token_value_warning')
]
end
rescue StandardError => e
Rails.logger.error "Failed to reset user ##{current_user.id} RSS key: #{e}"
flash[:error] = t('my.access_token.failed_to_reset_token', error: e.message)
ensure
redirect_to action: 'access_token'
end
# Create a new API key
def reset_api_key
if request.post?
if User.current.api_token
User.current.api_token.destroy
User.current.reload
end
User.current.api_key
flash[:notice] = l(:notice_api_access_key_reseted)
end
redirect_to action: 'access_token'
end
def generate_api_key
if request.post?
User.current.api_key
flash[:notice] = l(:notice_api_access_key_generated)
token = Token::Api.create!(user: current_user)
flash[:notice] = [
t('my.access_token.notice_reset_token', type: 'API'),
"<strong>#{token.plain_value}</strong>".html_safe,
t('my.access_token.token_value_warning')
]
end
rescue StandardError => e
Rails.logger.error "Failed to reset user ##{current_user.id} API key: #{e}"
flash[:error] = t('my.access_token.failed_to_reset_token', error: e.message)
ensure
redirect_to action: 'access_token'
end

@ -0,0 +1,36 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require_dependency 'token/hashed_token'
module Token
class Api < HashedToken
end
end

@ -0,0 +1,36 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require_dependency 'token/hashed_token'
module Token
class AutoLogin < HashedToken
end
end

@ -0,0 +1,64 @@
# Redmine - project management software
# Copyright (C) 2006-2009 Jean-Philippe Lang
# Adapted to fit needs for mOTP
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Token
class Base < ActiveRecord::Base
self.table_name = 'tokens'
# Hashed tokens belong to a user and are unique per type
belongs_to :user
# Create a plain and hashed value when creating a new token
after_initialize :initialize_values
# Ensure uniqueness of the token value
validates_presence_of :value
validates_uniqueness_of :value
# Delete previous token of this type upon save
before_save :delete_previous_token
##
# Find a token from the token value
def self.find_by_plaintext_value(input)
find_by(value: input)
end
##
# Generate a random hex token value
def self.generate_token_value
SecureRandom.hex(32)
end
protected
# Removes obsolete tokens (same user and action)
def delete_previous_token
if user
self.class.where(user_id: user.id, type: type).delete_all
end
end
def initialize_values
if new_record? && !value.present?
self.value = self.class.generate_token_value
end
end
end
end

@ -0,0 +1,57 @@
# Redmine - project management software
# Copyright (C) 2006-2009 Jean-Philippe Lang
# Adapted to fit needs for mOTP
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Token
module ExpirableToken
extend ActiveSupport::Concern
included do
# Set the expiration time
before_create :set_expiration_time
# Remove outdated token
after_save :delete_expired_tokens
def valid_plaintext?(input)
return false if expired?
super
end
def expired?
Time.now > (created_on + validity_time)
end
def validity_time
self.class.validity_time
end
##
# Set the expiration column
def set_expiration_time
self.expires_on = Time.now + validity_time
end
# Delete all expired tokens
def delete_expired_tokens
if validity_time
self.class.where(["expires_on < ?", Time.now]).delete_all
end
end
end
end
end

@ -0,0 +1,64 @@
# Redmine - project management software
# Copyright (C) 2006-2009 Jean-Philippe Lang
# Adapted to fit needs for mOTP
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require_dependency 'token/base'
module Token
class HashedToken < Base
# Allow access to the plain value during initial access / creation of the token
attr_reader :plain_value
class << self
def create_and_return_value(user)
create(user: user).plain_value
end
##
# Find a token from the token value
def find_by_plaintext_value(input)
find_by(value: hash_function(input))
end
end
##
# Validate the user input on the token
# 1. The token is still valid
# 2. The plain text matches the hash
def valid_plaintext?(input)
hashed_input = hash_function(input)
ActiveSupport::SecurityUtils.secure_compare hashed_input, value
end
def self.hash_function(input)
# Use a fixed salt for hashing token values.
# We still want to be able to index the hash value for fast lookups,
# so we need to determine the hash without knowing the associated user (and thus its salt) first.
Digest::SHA256.hexdigest(input + Rails.application.secrets.fetch(:secret_key_base))
end
delegate :hash_function, to: :class
private
def initialize_values
if new_record? && !value.present?
@plain_value = self.class.generate_token_value
self.value = hash_function(@plain_value)
end
end
end
end

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
@ -27,42 +28,16 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
class Token < ActiveRecord::Base
belongs_to :user
validates :user, presence: true
validates :action, presence: true
validates_uniqueness_of :value
before_create :delete_previous_tokens
before_create :assign_generated_token
@@validity_time = 1.day
# Return true if token has expired
def expired?
Time.now > created_on + @@validity_time
end
# Delete all expired tokens
def self.destroy_expired
Token.where(["action <> 'feeds' AND created_on < ?", Time.now - @@validity_time]).delete_all
end
def self.generate_token_value
SecureRandom.hex(20)
end
require_dependency 'token/base'
private
module Token
class Invitation < Base
include ExpirableToken
# Removes obsolete tokens (same user and action)
def delete_previous_tokens
if user
Token.where(user_id: user.id, action: action).delete_all
##
# Invitation tokens are valid for one day.
def self.validity_time
1.day
end
end
def assign_generated_token
self.value = self.class.generate_token_value
end
end

@ -0,0 +1,41 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require_dependency 'token/base'
module Token
class Recovery < Base
include ExpirableToken
def self.validity_time
1.day
end
end
end

@ -0,0 +1,45 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require_dependency 'token/base'
module Token
class Rss < Base
after_initialize do
unless value.present?
self.value = self.class.generate_token_value
end
end
def plain_value
value
end
end
end

@ -78,12 +78,8 @@ class User < Principal
}, class_name: 'UserPassword',
dependent: :destroy,
inverse_of: :user
has_one :rss_token, -> {
where("action='feeds'")
}, dependent: :destroy, class_name: 'Token'
has_one :api_token, -> {
where("action='api'")
}, dependent: :destroy, class_name: 'Token'
has_one :rss_token, class_name: '::Token::Rss', dependent: :destroy
has_one :api_token, class_name: '::Token::Api', dependent: :destroy
belongs_to :auth_source
# Users blocked via brute force prevention
@ -234,12 +230,12 @@ class User < Principal
def self.activate_user!(user, session)
if session[:invitation_token]
token = Token.find_by_value session[:invitation_token]
token = Token::Invitation.find_by_plaintext_value session[:invitation_token]
invited_id = token && token.user.id
if user.id == invited_id
user.activate!
token.destroy!
token.destroy
session.delete :invitation_token
end
end
@ -267,10 +263,9 @@ class User < Principal
# Returns the user who matches the given autologin +key+ or nil
def self.try_to_autologin(key)
tokens = Token.where(action: 'autologin', value: key)
token = Token::AutoLogin.find_by_plaintext_value(key)
# Make sure there's only 1 token that matches the key
if tokens.size == 1
token = tokens.first
if token
if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
token.user.log_successful_login
token.user
@ -444,18 +439,6 @@ class User < Principal
pref.comments_in_reverse_order?
end
# Return user's RSS key (a 40 chars long string), used to access feeds
def rss_key
token = rss_token || Token.create(user: self, action: 'feeds')
token.value
end
# Return user's API key (a 40 chars long string), used to access the API
def api_key
token = api_token || create_api_token(action: 'api')
token.value
end
# Return an array of project ids for which the user has explicitly turned mail notifications on
def notified_projects_ids
@notified_projects_ids ||= memberships.select(&:mail_notification?).map(&:project_id)
@ -497,13 +480,21 @@ class User < Principal
end
def self.find_by_rss_key(key)
token = Token.find_by(value: key)
token && token.user.active? && Setting.feeds_enabled? ? token.user : nil
return nil unless Setting.feeds_enabled?
token = Token::Rss.find_by(value: key)
if token && token.user.active?
token.user
end
end
def self.find_by_api_key(key)
token = Token.find_by(action: 'api', value: key)
token && token.user.active? ? token.user : nil
return nil unless Setting.rest_api_enabled?
token = Token::Api.find_by_plaintext_value(key)
if token && token.user.active?
token.user
end
end
# Makes find_by_mail case-insensitive
@ -511,6 +502,11 @@ class User < Principal
where(['LOWER(mail) = ?', mail.to_s.downcase]).first
end
def rss_key
token = rss_token || ::Token::Rss.create(user: self)
token.value
end
def to_s
name
end

@ -89,17 +89,9 @@ See doc/COPYRIGHT.rdoc for more details.
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to l(:button_reset),
{ action: 'reset_rss_key' },
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-delete' %>
<a href=""
click-notification="<%= l(:present_access_key_value,
key_name: l(:label_feeds_access_key),
value: @user.rss_token.value) %>"
click-notification-type="notice"
class="icon icon-key">
<%= l(:button_show) %>
</a>
</td>
</tr>
<% else %>
@ -129,17 +121,9 @@ See doc/COPYRIGHT.rdoc for more details.
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to l(:button_reset),
{ action: 'reset_api_key' },
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-delete' %>
<a href=""
click-notification="<%= l(:present_access_key_value,
key_name: l(:label_api_access_key),
value: @user.api_token.value) %>"
click-notification-type="notice"
class="icon icon-key">
<%= l(:button_show) %>
</a>
</td>
</tr>
<% else %>

@ -43,6 +43,6 @@ class DeliverInvitationJob < ApplicationJob
end
def token
@token ||= Token.find_by(id: @token_id)
@token ||= Token::Invitation.find_by(id: @token_id)
end
end

@ -150,6 +150,9 @@ en:
my:
access_token:
failed_to_reset_token: "Failed to reset access token: %{error}"
notice_reset_token: "A new %{type} token has been generated. Your access token is:"
token_value_warning: "Note: This is the only time you will see this token, make sure to copy it now."
no_results_title_text: There are currently no access tokens available.
news:
@ -1596,16 +1599,12 @@ en:
notice_account_wrong_password: "Wrong password"
notice_account_registered_and_logged_in: "Welcome, your account has been activated. You are logged in now."
notice_activation_failed: The account could not be activated.
notice_api_access_key_reseted: "Your API access key was reset."
notice_api_access_key_generated: "Your API access key was generated."
notice_can_t_change_password: "This account uses an external authentication source. Impossible to change the password."
notice_custom_options_deleted: "Option '%{option_value}' and its %{num_deleted} occurrences were deleted."
notice_email_error: "An error occurred while sending mail (%{value})"
notice_email_sent: "An email was sent to %{value}"
notice_failed_to_save_work_packages: "Failed to save %{count} work package(s) on %{total} selected: %{ids}."
notice_failed_to_save_members: "Failed to save member(s): %{errors}."
notice_feeds_access_key_reseted: "Your RSS access key was reset."
notice_feeds_access_key_generated: "Your RSS access key was generated."
notice_file_not_found: "The page you were trying to access doesn't exist or has been removed."
notice_forced_logout: "You have been automatically logged out after %{ttl_time} minutes of inactivity."

@ -558,9 +558,7 @@ OpenProject::Application.routes.draw do
match '/my/account', action: 'account', via: [:get, :patch]
match '/my/settings', action: 'settings', via: [:get, :patch]
match '/my/mail_notifications', action: 'mail_notifications', via: [:get, :patch]
post '/my/reset_rss_key', action: 'reset_rss_key'
post '/my/generate_rss_key', action: 'generate_rss_key'
post '/my/reset_api_key', action: 'reset_api_key'
post '/my/generate_api_key', action: 'generate_api_key'
get '/my/access_token', action: 'access_token'
end

@ -0,0 +1,48 @@
class MoveHashedTokenToCore < ActiveRecord::Migration[5.0]
class OldToken < ActiveRecord::Base
self.table_name = :plaintext_tokens
end
def up
rename_table :tokens, :plaintext_tokens
create_tokens_table
migrate_existing_tokens
end
def down
drop_table :tokens
rename_table :plaintext_tokens, :tokens
end
private
def create_tokens_table
create_table :tokens do |t|
t.references :user, index: true
t.string :type
t.string :value, default: "", null: false, limit: 128
t.datetime :created_on, null: false
t.datetime :expires_on, null: true
end
end
def migrate_existing_tokens
# API tokens
::Token::Api.transaction do
OldToken.where(action: 'api').find_each do |token|
result = ::Token::Api.create(user_id: token.user_id, value: ::Token::Api.hash_function(token.value))
warn "Failed to migrate API token for ##{user.id}" unless result
end
end
# RSS tokens
::Token::Rss.transaction do
OldToken.where(action: 'feeds').find_each do |token|
result = ::Token::Rss.create(user_id: token.user_id, value: token.value)
warn "Failed to migrate RSS token for ##{user.id}" unless result
end
end
# We do not migrate the rest, they are short-lived anyway.
end
end

@ -147,6 +147,6 @@ Given /^the user "(.+)" is(not |) forced to change his password$/ do |login, dis
end
Given /^I use the first existing token to request a password reset$/ do
token = Token.first
token = Token::Recovery.first
visit account_lost_password_path(token: token.value)
end

@ -325,7 +325,7 @@ describe AccountController, type: :controller do
end
context 'with self registration off but an ongoing invitation activation' do
let(:token) { FactoryGirl.create :token }
let(:token) { FactoryGirl.create :invitation_token }
before do
allow(Setting).to receive(:self_registration).and_return('0')
@ -401,7 +401,7 @@ describe AccountController, type: :controller do
context 'with password login enabled' do
before do
Token.delete_all
Token::Invitation.delete_all
post :register,
params: {
user: {
@ -421,8 +421,7 @@ describe AccountController, type: :controller do
it "doesn't activate the user but sends out a token instead" do
expect(User.find_by_login('register')).not_to be_active
token = Token.first
expect(token.action).to eq('register')
token = Token::Invitation.last
expect(token.user.mail).to eq('register@example.com')
expect(token).not_to be_expired
end

@ -78,7 +78,7 @@ describe Api::V2::AuthenticationController, type: :controller do
end
describe 'session' do
let(:api_key) { user.api_key }
let(:api_key) { ::Token::Api.create!(user: user).plain_value }
let(:user) { FactoryGirl.create(:admin) }
let(:ttl) { 42 }
@ -111,7 +111,7 @@ describe Api::V2::AuthenticationController, type: :controller do
end
describe 'WWW-Authenticate response header upon failure' do
let(:api_key) { user.api_key }
let(:api_key) { ::Token::Api.create(user: user).plain_value }
let(:user) { FactoryGirl.create(:admin) }
let(:ttl) { 42 }

@ -49,7 +49,7 @@ describe UserInvitation do
describe '.reinvite_user' do
let(:user) { FactoryGirl.create :invited_user }
let!(:token) { FactoryGirl.create :token, user: user, action: UserInvitation.token_action }
let!(:token) { FactoryGirl.create :invitation_token, user: user }
it 'notifies listeners of the re-invite' do
expect(OpenProject::Notifications).to receive(:send) do |event, new_token|
@ -63,7 +63,7 @@ describe UserInvitation do
new_token = UserInvitation.reinvite_user user.id
expect(new_token.value).not_to eq token.value
expect(Token.exists?(token.id)).to eq false
expect(Token::Invitation.exists?(token.id)).to eq false
end
end
end

@ -228,4 +228,72 @@ describe MyController, type: :controller do
expect(response.body).to have_selector('h3', text: /#{label}.*42/)
end
end
describe 'access_tokens' do
describe 'rss' do
it 'creates a key' do
expect(user.rss_token).to eq(nil)
post :generate_rss_key
expect(user.reload.rss_token).to be_present
expect(flash[:notice]).to be_present
expect(flash[:error]).not_to be_present
expect(response).to redirect_to action: :access_token
end
context 'with existing key' do
let!(:key) { ::Token::Rss.create user: user }
it 'replaces the key' do
expect(user.rss_token).to eq(key)
post :generate_rss_key
new_token = user.reload.rss_token
expect(new_token).not_to eq(key)
expect(new_token.value).not_to eq(key.value)
expect(new_token.value).to eq(user.rss_key)
expect(flash[:notice]).to be_present
expect(flash[:error]).not_to be_present
expect(response).to redirect_to action: :access_token
end
end
end
describe 'api' do
context 'with no existing key' do
it 'creates a key' do
expect(user.api_token).to eq(nil)
post :generate_api_key
new_token = user.reload.api_token
expect(new_token).to be_present
expect(flash[:notice]).to be_present
expect(flash[:error]).not_to be_present
expect(response).to redirect_to action: :access_token
end
end
context 'with existing key' do
let!(:key) { ::Token::Api.create user: user }
it 'replaces the key' do
expect(user.reload.api_token).to eq(key)
post :generate_api_key
new_token = user.reload.api_token
expect(new_token).not_to eq(key)
expect(new_token.value).not_to eq(key.value)
expect(flash[:notice]).to be_present
expect(flash[:error]).not_to be_present
expect(response).to redirect_to action: :access_token
end
end
end
end
end

@ -159,7 +159,7 @@ describe UsersController, type: :controller do
it 'sends another activation email' do
mail = ActionMailer::Base.deliveries.first.body.parts.first.body.to_s
token = Token.find_by user_id: invited_user.id, action: UserInvitation.token_action
token = Token::Invitation.find_by user_id: invited_user.id
expect(mail).to include 'activate your account'
expect(mail).to include token.value

@ -29,17 +29,19 @@
require 'securerandom'
FactoryGirl.define do
factory :token do
factory :invitation_token, class: ::Token::Invitation do
user
action 'invite'
value do SecureRandom.hex(16) end
end
factory :api_key do
action 'api'
end
factory :api_token, class: ::Token::Api do
user
end
factory :rss_key do
action 'rss'
end
factory :rss_token, class: ::Token::Rss do
user
end
factory :recovery_token, class: ::Token::Recovery do
user
end
end

@ -102,32 +102,30 @@ describe 'my', type: :feature, js: true do
end
end
it 'in Access Tokens they can generate and view their API key' do
it 'in Access Tokens they can generate their API key' do
visit my_access_token_path
expect(page).to have_content 'Missing API access key'
find(:xpath, "//tr[contains(.,'API')]/td/a", text: 'Generate').click
expect(page).to have_content 'Your API access key was generated.'
expect(page).not_to have_content 'Missing API access key'
expect(page).to have_content 'A new API token has been generated. Your access token is'
User.current.reload
visit my_access_token_path
find(:xpath, "//tr[contains(.,'API')]/td/a", text: 'Show').click
expect(page).to have_content "Your API access key is: #{user.api_token.value}"
expect(page).not_to have_content 'Missing API access key'
end
it 'in Access Tokens they can generate and view their RSS key' do
it 'in Access Tokens they can generate their RSS key' do
visit my_access_token_path
expect(page).to have_content 'Missing RSS access key'
find(:xpath, "//tr[contains(.,'RSS')]/td/a", text: 'Generate').click
expect(page).to have_content 'Your RSS access key was generated.'
expect(page).to have_content 'A new RSS token has been generated. Your access token is'
User.current.reload
visit my_access_token_path
expect(page).not_to have_content 'Missing RSS access key'
find(:xpath, "//tr[contains(.,'RSS')]/td/a", text: 'Show').click
expect(page).to have_content "Your RSS access key is: #{user.rss_token.value}"
end
end
end

@ -43,17 +43,4 @@ feature 'resend invitation', type: :feature do
expect(page).to have_text 'Another invitation has been sent to holly@openproject.com.'
end
context 'with some error occuring' do
before do
allow(UserInvitation).to receive(:token_action).and_return(nil)
end
scenario 'resending fails' do
click_on 'Resend invitation'
expect(page).to have_text 'An error occurred'
expect(page).to have_text 'You are here: HomeAdministrationUsers'
end
end
end

@ -1,4 +1,3 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
@ -26,24 +25,24 @@
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'legacy_spec_helper'
describe Token do
fixtures :all
require 'spec_helper'
describe ::Token::Base, type: :model do
let(:user) { FactoryGirl.build(:user) }
subject { described_class.new user: user }
it 'should create' do
token = Token.new user: User.find(1), action: 'foobar'
token.save
assert_equal 40, token.value.length
assert !token.expired?
subject.save!
assert_equal 64, subject.value.length
end
it 'should create_should_remove_existing_tokens' do
user = User.find(1)
t1 = Token.create(user: user, action: 'autologin')
t2 = Token.create(user: user, action: 'autologin')
refute_equal t1.value, t2.value
assert !Token.exists?(t1.id)
assert Token.exists?(t2.id)
subject.save!
t2 = Token::AutoLogin.create user: user
expect(subject.value).not_to eq(t2.value)
expect(Token::AutoLogin.exists?(subject.id)).to eq false
expect(Token::AutoLogin.exists?(t2.id)).to eq true
end
end

@ -0,0 +1,67 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::Token::HashedToken, type: :model do
let(:user) { FactoryGirl.build(:user) }
subject { described_class.new user: user }
describe 'token value' do
it 'is generated on a new instance' do
expect(subject.value).to be_present
end
it 'provides the generated plain value on a new instance' do
expect(subject.valid_plaintext?(subject.plain_value)).to eq true
end
it 'hashes the plain value to value' do
expect(subject.value).not_to eq(subject.plain_value)
end
it 'does not keep the value when finding it' do
subject.save!
instance = described_class.where(user: user).last
expect(instance.plain_value).to eq nil
end
end
describe '#find_by_plaintext_value' do
before do
subject.save!
end
it 'finds using the plaintext value' do
expect(described_class.find_by_plaintext_value(subject.plain_value)).to eq subject
expect(described_class.find_by_plaintext_value('foobar')).to eq nil
end
end
end

@ -361,9 +361,7 @@ describe User, 'deletion', type: :model do
describe 'WHEN the user has a token created' do
let(:token) {
Token.new(user: user,
action: 'feeds',
value: 'loremipsum')
Token::Rss.new(user: user, value: 'loremipsum')
}
before do
@ -372,7 +370,7 @@ describe User, 'deletion', type: :model do
user.destroy
end
it { expect(Token.find_by(id: token.id)).to be_nil }
it { expect(Token::Rss.find_by(id: token.id)).to be_nil }
end
describe 'WHEN the user has created a private query' do

@ -166,10 +166,10 @@ describe API::V3, type: :request do
it_behaves_like 'it is basic auth protected'
describe 'user basic auth' do
let(:api_key) { FactoryGirl.create :api_key }
let(:api_key) { FactoryGirl.create :api_token }
let(:username) { 'apikey' }
let(:password) { api_key.value }
let(:password) { api_key.plain_value }
# check that user basic auth is tried when global basic auth fails
it_behaves_like 'it is basic auth protected'
@ -177,10 +177,10 @@ describe API::V3, type: :request do
end
describe 'user basic auth' do
let(:api_key) { FactoryGirl.create :api_key }
let(:api_key) { FactoryGirl.create :api_token }
let(:username) { 'apikey' }
let(:password) { api_key.value }
let(:password) { api_key.plain_value }
# check that user basic auth works on its own too
it_behaves_like 'it is basic auth protected'
@ -199,7 +199,7 @@ describe API::V3, type: :request do
let(:password) { 'olooleol' }
let(:api_user) { FactoryGirl.create :user, login: 'user_account' }
let(:api_key) { FactoryGirl.create :api_key, user: api_user }
let(:api_key) { FactoryGirl.create :api_token, user: api_user }
before do
config = { user: 'global_account', password: 'global_password' }
@ -249,7 +249,7 @@ describe API::V3, type: :request do
context 'with valid user credentials' do
before do
set_basic_auth_header('apikey', api_key.value)
set_basic_auth_header('apikey', api_key.plain_value)
get resource
end

@ -49,7 +49,7 @@ describe 'API v2', type: :request do
end
describe 'API authentication' do
let(:api_key) { admin.api_key }
let(:api_key) { ::Token::Api.create(user: admin).plain_value }
shared_examples_for 'API key access' do
context 'invalid' do

@ -49,11 +49,11 @@ describe 'my routes', type: :routing do
expect(patch('/my/account')).to route_to('my#account')
end
it '/my/reset_rss_key POST routes to my#reset_rss_key' do
expect(post('/my/reset_rss_key')).to route_to('my#reset_rss_key')
it '/my/generate_rss_key POST routes to my#generate_rss_key' do
expect(post('/my/generate_rss_key')).to route_to('my#generate_rss_key')
end
it '/my/reset_api_key POST routes to my#reset_api_key' do
expect(post('/my/reset_api_key')).to route_to('my#reset_api_key')
it '/my/generate_api_key POST routes to my#generate_api_key' do
expect(post('/my/generate_api_key')).to route_to('my#generate_api_key')
end
end

@ -29,13 +29,13 @@
---
tokens_001:
created_on: 2007-01-21 00:39:12 +01:00
action: register
type: Token::Invitation
id: 1
value: DwMJ2yIxBNeAk26znMYzYmz5dAiIina0GFrPnGTM
user_id: 1
tokens_002:
created_on: 2007-01-21 00:39:52 +01:00
action: recovery
id: 2
value: sahYSIaoYrsZUef86sTHrLISdznW6ApF36h5WSnm
type: Token::Recovery
user_id: 2

@ -83,72 +83,4 @@ describe MyController, type: :controller do
assert_response :success
assert_equal ['documents', 'calendar', 'latestnews'], User.find(2).pref[:my_page_layout]['left']
end
context 'POST to reset_rss_key' do
context 'with an existing rss_token' do
before do
@previous_token_value = User.find(2).rss_key # Will generate one if it's missing
post :reset_rss_key
end
it 'should destroy the existing token' do
refute_equal @previous_token_value, User.find(2).rss_key
end
it 'should create a new token' do
assert User.find(2).rss_token
end
it { is_expected.to set_flash.to /reset/ }
it { is_expected.to redirect_to '/my/access_token' }
end
context 'with no rss_token' do
before do
assert_nil User.find(2).rss_token
post :reset_rss_key
end
it 'should create a new token' do
assert User.find(2).rss_token
end
it { is_expected.to set_flash.to /reset/ }
it { is_expected.to redirect_to '/my/access_token' }
end
end
context 'POST to reset_api_key' do
context 'with an existing api_token' do
before do
@previous_token_value = User.find(2).api_key # Will generate one if it's missing
post :reset_api_key
end
it 'should destroy the existing token' do
refute_equal @previous_token_value, User.find(2).api_key
end
it 'should create a new token' do
assert User.find(2).api_token
end
it { is_expected.to set_flash.to /reset/ }
it { is_expected.to redirect_to '/my/access_token' }
end
context 'with no api_token' do
before do
assert_nil User.find(2).api_token
post :reset_api_key
end
it 'should create a new token' do
assert User.find(2).api_token
end
it { is_expected.to set_flash.to /reset/ }
it { is_expected.to redirect_to '/my/access_token' }
end
end
end

@ -371,13 +371,13 @@ describe UserMailer, type: :mailer do
it 'should lost password' do
user = FactoryGirl.create(:user)
token = FactoryGirl.create(:token, user: user)
token = FactoryGirl.create(:recovery_token, user: user)
assert UserMailer.password_lost(token).deliver_now
end
it 'should register' do
user = FactoryGirl.create(:user)
token = FactoryGirl.create(:token, user: user)
token = FactoryGirl.create(:invitation_token, user: user)
Setting.host_name = 'redmine.foo'
Setting.protocol = 'https'

@ -47,7 +47,7 @@ describe 'ApiTest: DisabledRestApiTest', type: :request do
context 'with a valid api token' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
get "/api/v2/projects.xml?key=#{@token.value}"
end
@ -75,7 +75,7 @@ describe 'ApiTest: DisabledRestApiTest', type: :request do
context 'with a valid HTTP authentication using the API token' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
@authorization = ActionController::HttpAuthentication::Basic.encode_credentials(@token.value, 'X')
get '/api/v2/projects.xml', params: { authorization: @authorization }
end
@ -92,7 +92,7 @@ describe 'ApiTest: DisabledRestApiTest', type: :request do
context 'with a valid api token' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
get "/api/v2/projects.json?key=#{@token.value}"
end
@ -120,7 +120,7 @@ describe 'ApiTest: DisabledRestApiTest', type: :request do
context 'with a valid HTTP authentication using the API token' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
@authorization = ActionController::HttpAuthentication::Basic.encode_credentials(@token.value, 'DoesNotMatter')
get '/api/v2/projects.json', params: { authorization: @authorization }
end

@ -342,9 +342,9 @@ module LegacyAssertionsAndHelpers
context 'with a valid HTTP authentication using the API token' do
before do
@user = FactoryGirl.create(:user, admin: true)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
send(http_method, url, params: parameters, headers: credentials(@token.value, 'X'))
send(http_method, url, params: parameters, headers: credentials(@token.plain_value, 'X'))
end
it { should respond_with success_code }
it { should_respond_with_content_type_based_on_url(url) }
@ -357,7 +357,7 @@ module LegacyAssertionsAndHelpers
context 'with an invalid HTTP authentication' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'feeds')
@token = FactoryGirl.create(:rss_token, user: @user)
send(http_method, url, params: parameters, headers: credentials(@token.value, 'X'))
end
@ -386,12 +386,12 @@ module LegacyAssertionsAndHelpers
context 'with a valid api token' do
before do
@user = FactoryGirl.create(:user, admin: true)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
@token = FactoryGirl.create(:api_token, user: @user)
# Simple url parse to add on ?key= or &key=
request_url = if url.match(/\?/)
url + "&key=#{@token.value}"
url + "&key=#{@token.plain_value}"
else
url + "?key=#{@token.value}"
url + "?key=#{@token.plain_value}"
end
send(http_method, request_url, params: parameters)
end
@ -406,7 +406,7 @@ module LegacyAssertionsAndHelpers
context 'with an invalid api token' do
before do
@user = FactoryGirl.create(:user)
@token = FactoryGirl.create(:token, user: @user, action: 'feeds')
@token = FactoryGirl.create(:rss_token, user: @user)
# Simple url parse to add on ?key= or &key=
request_url = if url.match(/\?/)
url + "&key=#{@token.value}"
@ -426,8 +426,8 @@ module LegacyAssertionsAndHelpers
context "should allow key based auth using X-OpenProject-API-Key header for #{http_method} #{url}" do
before do
@user = FactoryGirl.create(:user, admin: true)
@token = FactoryGirl.create(:token, user: @user, action: 'api')
send(http_method, url, params: {}, headers: { 'X-OpenProject-API-Key' => @token.value.to_s })
@token = FactoryGirl.create(:api_token, user: @user)
send(http_method, url, params: {}, headers: { 'X-OpenProject-API-Key' => @token.plain_value.to_s })
end
it { should respond_with success_code }
it { should_respond_with_content_type_based_on_url(url) }

@ -272,7 +272,7 @@ describe User, type: :model do
it 'should rss key' do
assert_nil @jsmith.rss_token
key = @jsmith.rss_key
assert_equal 40, key.length
assert_equal 64, key.length
@jsmith.reload
assert_equal key, @jsmith.rss_key
@ -280,27 +280,6 @@ describe User, type: :model do
it { is_expected.to have_one :api_token }
context 'User#api_key' do
it "should generate a new one if the user doesn't have one" do
user = FactoryGirl.create(:user, api_token: nil)
assert_nil user.api_token
key = user.api_key
assert_equal 40, key.length
user.reload
assert_equal key, user.api_key
end
it 'should return the existing api token value' do
user = FactoryGirl.create(:user)
token = FactoryGirl.create(:token, action: 'api')
user.api_token = token
assert user.save
assert_equal token.value, user.api_key
end
end
context 'User#find_by_api_key' do
it 'should return nil if no matching key is found' do
assert_nil User.find_by_api_key('zzzzzzzzz')
@ -308,7 +287,7 @@ describe User, type: :model do
it 'should return nil if the key is found for an inactive user' do
user = FactoryGirl.create(:user, status: User::STATUSES[:locked])
token = FactoryGirl.create(:token, action: 'api')
token = FactoryGirl.build(:api_token, user: user)
user.api_token = token
user.save
@ -317,11 +296,11 @@ describe User, type: :model do
it 'should return the user if the key is found for an active user' do
user = FactoryGirl.create(:user, status: User::STATUSES[:active])
token = FactoryGirl.create(:token, action: 'api')
token = FactoryGirl.build(:api_token, user: user)
user.api_token = token
user.save
assert_equal user, User.find_by_api_key(token.value)
assert_equal user, User.find_by_api_key(token.plain_value)
end
end

Loading…
Cancel
Save