1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-28 09:24:24 +01:00

(feat) improved security when changing password

This commit is contained in:
Sylvain 2022-12-21 19:24:54 +01:00
parent 1a6605fbef
commit 137b9f3c1b
20 changed files with 310 additions and 31 deletions

View File

@ -12,7 +12,7 @@ CommitMsg:
MessageFormat:
enabled: true
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge) title\n\ndescription
pattern: ^(\((doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip)\) [\w ]++(\n\n.+)?)|(Version (\d+\.?)+)|(Merge branch .*)
expected_pattern_message: (doc|bug|feat|security|dev|i18n|api|test|quality|ui|merge|wip) title\n\ndescription
sample_message: (bug) no validation on date\n\nThe birthdate was not validated...

View File

@ -6,6 +6,8 @@
- Ability to define multiple accounting journal codes
- OpenAPI endpoint to fetch accounting data
- Add reservation deadline parameter (#414)
- Verify current password at server side when changing password
- Password strengh indicator
- Updated OpenAPI documentation
- Updated OpenID Connect documentation
- OpenAPI users endpoint offer ability to filter by created_after

View File

@ -47,7 +47,7 @@ class API::MembersController < API::ApiController
authorize @member
members_service = Members::MembersService.new(@member)
if members_service.update(user_params)
if members_service.update(user_params, current_user, params[:user][:current_password])
# Update password without logging out
bypass_sign_in(@member) unless current_user.id != params[:id].to_i
render :show, status: :ok, location: member_path(@member)
@ -235,7 +235,7 @@ class API::MembersController < API::ApiController
],
statistic_profile_attributes: %i[id gender birthday])
elsif current_user.admin? || current_user.manager?
elsif current_user.privileged?
params.require(:user).permit(:username, :email, :password, :password_confirmation, :is_allow_contact, :is_allow_newsletter, :group_id,
tag_ids: [],
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,

View File

@ -1,19 +1,17 @@
# frozen_string_literal: true
# Devise controller used for the "forgotten password" feature
# Devise controller used for the "forgotten password" feature and to check the current's user password
class PasswordsController < Devise::PasswordsController
# POST /users/password.json
def create
self.resource = resource_class.send_reset_password_instructions(resource_params)
yield resource if block_given?
if successfully_sent?(resource)
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
end
respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) if successfully_sent?(resource)
end
# POST /password/verify
def verify
current_user.valid_password?(params[:password]) ? head(200) : head(404)
current_user.valid_password?(params[:password]) ? head(:ok) : head(:not_found)
end
end

View File

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import Authentication from '../../api/authentication';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { PasswordInput } from './password-input';
import { FormState } from 'react-hook-form/dist/types/form';
import { FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
import MemberAPI from '../../api/member';
import { User } from '../../models/user';
@ -18,13 +18,14 @@ interface ChangePasswordProp<TFieldValues> {
currentFormPassword: string,
formState: FormState<TFieldValues>,
user: User,
setValue: UseFormSetValue<User>,
}
/**
* This component shows a button that trigger a modal dialog to verify the user's current password.
* If the user's current password is correct, the modal dialog is closed and the button is replaced by a form to set the new password.
*/
export const ChangePassword = <TFieldValues extends FieldValues>({ register, onError, currentFormPassword, formState, user }: ChangePasswordProp<TFieldValues>) => {
export const ChangePassword = <TFieldValues extends FieldValues>({ register, onError, currentFormPassword, formState, user, setValue }: ChangePasswordProp<TFieldValues>) => {
const { t } = useTranslation('shared');
const [isModalOpen, setIsModalOpen] = React.useState<boolean>(false);
@ -68,6 +69,7 @@ export const ChangePassword = <TFieldValues extends FieldValues>({ register, onE
return handleSubmit((data: { password: string }) => {
Authentication.verifyPassword(data.password).then(res => {
if (res) {
setValue('current_password', data.password);
setIsConfirmedPassword(true);
toggleConfirmationModal();
} else {
@ -85,6 +87,7 @@ export const ChangePassword = <TFieldValues extends FieldValues>({ register, onE
{t('app.shared.change_password.change_my_password')}
</FabButton>}
{isConfirmedPassword && <div className="password-fields">
<FormInput register={register} id="current_password" type="hidden" label="current password" />
<PasswordInput register={register} currentFormPassword={currentFormPassword} formState={formState} />
</div>}
<FabModal isOpen={isModalOpen} toggleModal={toggleConfirmationModal} title={t('app.shared.change_password.change_my_password')} closeButton>

View File

@ -3,6 +3,10 @@ import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormState } from 'react-hook-form/dist/types/form';
import { PasswordStrength } from './password-strength';
import * as React from 'react';
import { useState } from 'react';
import { Eye, EyeSlash } from 'phosphor-react';
interface PasswordInputProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
@ -16,9 +20,32 @@ interface PasswordInputProps<TFieldValues> {
export const PasswordInput = <TFieldValues extends FieldValues>({ register, currentFormPassword, formState }: PasswordInputProps<TFieldValues>) => {
const { t } = useTranslation('shared');
const [password, setPassword] = useState<string>(null);
const [inputType, setInputType] = useState<'password'|'text'>('password');
/**
* Callback triggered when the user types a password
*/
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
};
/**
* Switch the password characters between hidden and displayed
*/
const toggleShowPassword = () => {
if (inputType === 'text') {
setInputType('password');
} else {
setInputType('text');
}
};
return (
<div className="password-input">
<FormInput id="password" register={register}
addOn={inputType === 'password' ? <Eye size={24} /> : <EyeSlash size={24} />}
addOnAction={toggleShowPassword}
rules={{
required: true,
validate: (value: string) => {
@ -29,9 +56,11 @@ export const PasswordInput = <TFieldValues extends FieldValues>({ register, curr
}
}}
formState={formState}
onChange={handlePasswordChange}
label={t('app.shared.password_input.new_password')}
tooltip={t('app.shared.password_input.help')}
type="password" />
type={inputType} />
<PasswordStrength password={password} />
<FormInput id="password_confirmation"
register={register}
rules={{

View File

@ -0,0 +1,78 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import { debounce as _debounce } from 'lodash';
import LocaliseLib from '../../lib/localise';
import type { ZxcvbnResult } from '@zxcvbn-ts/core/src/types';
import { useTranslation } from 'react-i18next';
interface PasswordStrengthProps {
password?: string,
}
const SPECIAL_CHARS = ['!', '#', '$', '%', '&', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~', "'", '`', '"'];
/**
* Shows a visual indicator of the password strength
*/
export const PasswordStrength: React.FC<PasswordStrengthProps> = ({ password }) => {
const { t } = useTranslation('shared');
const [strength, setStrength] = useState<ZxcvbnResult>(null);
const [hasRequirements, setHasRequirements] = useState<boolean>(false);
/*
* zxcvbn library options
* @see https://zxcvbn-ts.github.io/zxcvbn/guide/getting-started/
*/
const options = {
translations: null,
graphs: zxcvbnCommonPackage.adjacencyGraphs,
dictionary: LocaliseLib.zxcvbnDictionnaries()
};
zxcvbnOptions.setOptions(options);
/**
* Compute the strength of the given password and update the result in memory
*/
const updateStrength = () => {
if (typeof password === 'string') {
if (checkRequirements()) {
setHasRequirements(true);
const result = zxcvbn(password);
setStrength(result);
} else {
setHasRequirements(false);
}
}
};
/**
* Check if the provided password meet the minimal requirements
*/
const checkRequirements = (): boolean => {
if (typeof password === 'string') {
const chars = password.split('');
return (chars.some(c => SPECIAL_CHARS.includes(c)) &&
!!password.match(/[A-Z]/) &&
!!password.match(/[a-z]/) &&
!!password.match(/[0-9]/) &&
password.length >= 12);
}
};
useEffect(_debounce(updateStrength, 500), [password]);
return (
<div className="password-strength">
{password && !hasRequirements && <>
<span className="requirements-error">{t('app.shared.password_strength.not_in_requirements')}</span>
</>}
{hasRequirements && strength && <>
<div className={`strength-bar strength-${strength.score}`} />
<span>{t(`app.shared.password_strength.${strength.score}`)}</span>
</>}
</div>
);
};

View File

@ -255,7 +255,8 @@ export const UserProfileForm: React.FC<UserProfileFormProps> = ({ action, size,
onError={onError}
currentFormPassword={output.password}
user={user}
formState={formState} />}
formState={formState}
setValue={setValue} />}
{action === 'create' && <PasswordInput register={register}
currentFormPassword={output.password}
formState={formState} />}

View File

@ -0,0 +1,46 @@
import { IFablab } from '../models/fablab';
import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import zxcvbnEnPackage from '@zxcvbn-ts/language-en';
import zxcvbnDePackage from '@zxcvbn-ts/language-de';
import zxcvbnEsPackage from '@zxcvbn-ts/language-es-es';
import zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
import zxcvbnPtPackage from '@zxcvbn-ts/language-pt-br';
declare let Fablab: IFablab;
/**
* Localization specific handlers
*/
export default class LocaliseLib {
/**
* Bind the dictionnaries for the zxcvbn lib, to the current locale configuration of the app (APP_LOCALE).
*/
static zxcvbnDictionnaries = () => {
switch (Fablab.locale) {
case 'de':
return {
...zxcvbnCommonPackage.dictionary,
...zxcvbnDePackage.dictionary
};
case 'es':
return {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEsPackage.dictionary
};
case 'fr':
return {
...zxcvbnCommonPackage.dictionary,
...zxcvbnFrPackage.dictionary
};
case 'pt':
return {
...zxcvbnCommonPackage.dictionary,
...zxcvbnPtPackage.dictionary
};
default:
return {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEnPackage.dictionary
};
}
};
}

View File

@ -19,6 +19,7 @@ export interface User {
need_completion?: boolean,
ip_address?: string,
mapped_from_sso?: string[],
current_password?: string,
password?: string,
password_confirmation?: string,
cgu?: boolean, // Accepted terms and conditions?

View File

@ -132,8 +132,10 @@
@import "modules/trainings/training-form";
@import "modules/user/avatar";
@import "modules/user/avatar-input";
@import "modules/user/change-password";
@import "modules/user/gender-input";
@import "modules/user/member-select";
@import "modules/user/password-strength";
@import "modules/user/user-profile-form";
@import "modules/user/user-validation";

View File

@ -0,0 +1,15 @@
.change-password {
.password-input {
animation: show 600ms 100ms cubic-bezier(0.38, 0.97, 0.56, 0.76) forwards;
opacity: 0;
transform: rotateX(-90deg);
transform-origin: top center;
}
}
@keyframes show {
100% {
opacity: 1;
transform: none;
}
}

View File

@ -0,0 +1,37 @@
.password-strength {
margin-top: -1rem;
margin-bottom: 1.6rem;
display: flex;
align-items: center;
.requirements-error {
color: var(--alert);
}
.strength-bar {
width: 100%;
height: 1rem;
border: 1px solid var(--gray-soft);
border-radius: 0.5rem;
&.strength-0 {
background: linear-gradient(to right, var(--alert), white 20%);
}
&.strength-1 {
background: linear-gradient(to right, var(--alert), orange 20%, white 40%);
}
&.strength-2 {
background: linear-gradient(to right, var(--alert), orange 20%, yellow 40%, white 60%);
}
&.strength-3 {
background: linear-gradient(to right, var(--alert), orange 20%, yellow 40%, #5e790f 60%, white 80%);
}
&.strength-4 {
background: linear-gradient(to right, var(--alert), orange 20%, yellow 40%, #5e790f 60%, var(--success) 80%);
}
}
span {
margin-left: 2rem;
min-width: fit-content;
white-space: nowrap;
}
}

View File

@ -255,7 +255,7 @@ class User < ApplicationRecord
end
def password_complexity
return if password.blank? || SecurePassword.is_secured?(password)
return if password.blank? || SecurePassword.secured?(password)
errors.add I18n.t('app.public.common.password_is_too_weak'), I18n.t('app.public.common.password_is_too_weak_explanations')
end

View File

@ -16,7 +16,7 @@ class Members::ImportService
params = row_to_params(row, user, password)
if user
service = Members::MembersService.new(user)
res = service.update(params)
res = service.update(params, import.user)
log << { user: user.id, status: 'update', result: res }
else
user = User.new(params)
@ -26,14 +26,10 @@ class Members::ImportService
end
log << user.errors.to_hash unless user.errors.to_hash.empty?
rescue StandardError => e
log << e.to_s
Rails.logger.error e
Rails.logger.debug e.backtrace
handle_error(log, e)
end
rescue ArgumentError => e
log << e.to_s
Rails.logger.error e
Rails.logger.debug e.backtrace
handle_error(log, e)
end
log
end
@ -184,4 +180,10 @@ class Members::ImportService
password
end
end
def handle_error(log, error)
log << error.to_s
Rails.logger.error error
Rails.logger.debug error.backtrace
end
end

View File

@ -8,7 +8,7 @@ class Members::MembersService
@member = member
end
def update(params)
def update(params, operator, current_password = nil)
if subscriber_group_change?(params)
# here a group change is requested but unprocessable, handle the exception
@member.errors.add(:group_id, I18n.t('members.unable_to_change_the_group_while_a_subscription_is_running'))
@ -30,6 +30,8 @@ class Members::MembersService
end
end
handle_password(params, operator, current_password)
Members::MembersService.handle_organization(params)
not_complete = member.need_completion?
@ -171,4 +173,14 @@ class Members::MembersService
def user_group_change?(params)
@member.group_id && params[:group_id] && @member.group_id != params[:group_id].to_i
end
def handle_password(params, operator, current_password = nil)
return unless params[:password] && params[:password_confirmation]
return if operator.privileged?
raise SecurityError, 'current password not provided' if current_password.blank?
raise SecurityError, 'current password does not match' unless @member.valid_password?(current_password)
end
end

View File

@ -1,18 +1,22 @@
# frozen_string_literal: true
# Ensure the passwords are secure enough
class SecurePassword
LOWER_LETTERS = ('a'..'z').to_a
UPPER_LETTERS = ('A'..'Z').to_a
DIGITS = ('0'..'9').to_a
SPECIAL_CHARS = ["!", "#", "$", "%", "&", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", "?", "@", "[", "]", "^", "_", "{", "|", "}", "~", "'", "`", '"']
SPECIAL_CHARS = ['!', '#', '$', '%', '&', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', ']', '^', '_', '{',
'|', '}', '~', "'", '`', '"'].freeze
def self.generate
(LOWER_LETTERS.shuffle.first(4) + UPPER_LETTERS.shuffle.first(4) + DIGITS.shuffle.first(4) + SPECIAL_CHARS.shuffle.first(4)).shuffle.join
(LOWER_LETTERS.sample(4) + UPPER_LETTERS.sample(4) + DIGITS.sample(4) + SPECIAL_CHARS.sample(4)).shuffle.join
end
def self.is_secured?(password)
password_as_array = password.split("")
password_as_array.any? {|c| c.in? LOWER_LETTERS } &&
password_as_array.any? {|c| c.in? UPPER_LETTERS } &&
password_as_array.any? {|c| c.in? DIGITS } &&
password_as_array.any? {|c| c.in? SPECIAL_CHARS }
def self.secured?(password)
password_as_array = password.chars
password_as_array.any? { |c| c.in? LOWER_LETTERS } &&
password_as_array.any? { |c| c.in? UPPER_LETTERS } &&
password_as_array.any? { |c| c.in? DIGITS } &&
password_as_array.any? { |c| c.in? SPECIAL_CHARS }
end
end
end

View File

@ -116,6 +116,13 @@ en:
help: "Your password must be minimum 12 characters long, have at least one uppercase letter, one lowercase letter, one number and one special character."
password_too_short: "Password is too short (must be at least 12 characters)"
confirmation_mismatch: "Confirmation mismatch with password."
password_strength:
not_in_requirements: "Your password doesn't meet the minimal requirements"
0: "Very weak password"
1: "Weak password"
2: "Almost ok"
3: "Good password"
4: "Excellent password"
#project edition form
project:
name: "Name"

View File

@ -81,6 +81,13 @@
"@types/react-dom": "^17.0.3",
"@types/sortablejs": "1",
"@uirouter/angularjs": "1.0.30",
"@zxcvbn-ts/core": "^2.1.0",
"@zxcvbn-ts/language-common": "^2.0.1",
"@zxcvbn-ts/language-de": "^2.1.0",
"@zxcvbn-ts/language-en": "^2.1.0",
"@zxcvbn-ts/language-es-es": "^2.1.1",
"@zxcvbn-ts/language-fr": "^2.2.0",
"@zxcvbn-ts/language-pt-br": "^2.1.0",
"AngularDevise": "https://github.com/cloudspace/angular_devise.git#1.0.2",
"angular": "1.8",
"angular-animate": "1.7",

View File

@ -3812,6 +3812,41 @@
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
"@zxcvbn-ts/core@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/core/-/core-2.1.0.tgz#026ffaba5b09cb05ee80f28b0183f75217d267d1"
integrity sha512-doxol9xrO7LgyVJhguXe7vO0xthnIYmsOKoDwrLg0Ho2kkpQaVtM+AOQw+BkEiKIqNg1V48eUf4/cTzMElXdiA==
"@zxcvbn-ts/language-common@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-common/-/language-common-2.0.1.tgz#01c371a64e86de417c317b4443aaa0a0f07f917b"
integrity sha512-P+v5MA/UNc9nb3FEOEoDgTyIGQc2vLc6m04pdf5YyuNOzrL0iNANhECk2TUp62JbrjouJVodqhMH0j1a8/24Bg==
"@zxcvbn-ts/language-de@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-de/-/language-de-2.1.0.tgz#c312638d6f520df40ac953cd0c1c3bcc69701bb3"
integrity sha512-VAk6D8+1eaeyatFU6Uz9Odiqu58e4VtyWzqdy2EmajAuGzZ+jpZLWtAlRG/qfElAFKR1B7SUp7tHRApzEJywvQ==
"@zxcvbn-ts/language-en@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-en/-/language-en-2.1.0.tgz#52c797914380546b191e5b915e0e9843116eae18"
integrity sha512-I3n4AAbArjPAZtwCrk9MQnSrcj5+9rq8sic2rUU44fP5QaR17Vk8zDt61+R9dnP9ZRsj09aAUYML4Ash05qZjQ==
"@zxcvbn-ts/language-es-es@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-es-es/-/language-es-es-2.1.1.tgz#960b28bf58e537547293d555a36b1c42ef1ce66b"
integrity sha512-uDXU/z1df6YGmacFVcFhsvQ2Uu/EbMFCjLeNoM/95vH3GCTb/10eI5IlzjgSP4EG305vd9oNpBy6MODu+9SvNg==
"@zxcvbn-ts/language-fr@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-fr/-/language-fr-2.2.0.tgz#0b7dd93ebba0044fbe733836bc7091b76d42afe1"
integrity sha512-KK+vIXm17mZyo7jLmV4T0fT6hh5NOBABdmkCBVpLyXq+rlZpdaz6HgoYLjqq2JbEU3KSZ+gv6qW+2N4dMk3Tlw==
"@zxcvbn-ts/language-pt-br@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-pt-br/-/language-pt-br-2.1.0.tgz#27676c0ee8df538ffc98bc7bca2817d492c70428"
integrity sha512-g4HxJLBf546BSfM4zDq29CNpAmFl2UsgHrEjy6gUA4KBEVqEaYNnMNfvayEtM7PpnzfZjSyLLVVG6S02lR8w+g==
"@zxing/text-encoding@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"