Implement HashedToken (ExtendedToken from MOTP) in Core

pull/6006/head
Oliver Günther 7 years ago
parent e6ebfd7279
commit 1f2492bb8b
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 1
      app/assets/stylesheets/content/_notifications.sass
  2. 16
      app/controllers/account_controller.rb
  3. 2
      app/controllers/concerns/omniauth_login.rb
  4. 6
      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. 71
      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. 5
      spec/controllers/account_controller_spec.rb
  22. 68
      spec/controllers/my_controller_spec.rb
  23. 2
      spec/controllers/users_controller_spec.rb
  24. 27
      spec/models/token/base_token_spec.rb
  25. 67
      spec/models/token/hashed_token_spec.rb
  26. 6
      spec/models/user_deletion_spec.rb
  27. 8
      spec/routing/my_spec.rb
  28. 68
      spec_legacy/functional/my_controller_spec.rb
  29. 21
      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,9 +154,9 @@ class AccountController < ApplicationController
# Token based account activation
def activate
token = Token.find_by value: params[:token].to_s
token = :Token::Invitation.find_by(value: params[:token])
if token && token.action == 'register' && Setting.self_registration?
if token && Setting.self_registration?
activate_self_registered token
else
activate_by_invite_token token
@ -361,7 +361,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,7 +439,7 @@ 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,
expires: 1.year.from_now,
@ -526,7 +526,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 +654,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 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

@ -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]
@ -163,6 +161,6 @@ module UserInvitation
end
def invitation_token(user)
Token.find_or_initialize_by user: user, action: token_action
Token::Invitation.new user: user
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,71 @@
# 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
private
##
# 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.
def token_salt
'28f939460f6f852268a534f449928af54026af6c16aebf04f5975307b9d72de389f0'.freeze
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)
Digest::SHA256.hexdigest(input + token_salt)
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_active_uuid 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(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

@ -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.delete_all
expect(token.user.mail).to eq('register@example.com')
expect(token).not_to be_expired
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

@ -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

@ -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

@ -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

@ -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')

Loading…
Cancel
Save