Skip to content

Commit

Permalink
Password change and password protected subforms
Browse files Browse the repository at this point in the history
  • Loading branch information
myovchev committed Jul 27, 2023
1 parent bf9dd34 commit c4512ba
Show file tree
Hide file tree
Showing 4 changed files with 436 additions and 29 deletions.
5 changes: 5 additions & 0 deletions modules/@apostrophecms/i18n/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,13 @@
"pages": "Pages",
"parentNotLocalized": "Localize the parent page first",
"password": "Password",
"passwordChangeHelp": "Modify your existing password",
"passwordCurrent": "Current Password",
"passwordCurrentError": "Current password is incorrect",
"passwordErrorMax": "Maximum of {{ max }} characters",
"passwordErrorMin": "Minimum of {{ min }} characters",
"passwordNew": "New Password",
"passwordRepeat": "Repeat New Password",
"passwordResetRequest": "Your request to reset your password on {{ site }}",
"pasteWidget": "Paste {{ widget }}",
"pending": "Pending",
Expand Down
243 changes: 229 additions & 14 deletions modules/@apostrophecms/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ module.exports = {
},

init(self) {
// List of all allowed protected types and their aliases (`subform.protected: type`).
// The key is the type or alias, the value is the actual type (always a string).
// All subforms `protected` prop will be converted to the actual type.
// Invalid protection types will panic.
self.protectedTypes = {
true: 'password',
password: 'password',
email: 'email'
};
// Collection of fieldName: protectedType objects for system forced protected fields.
// The order is important, the first match is used (first have higher priority).
// If there are multiple fields in the subform, having a system protected field,
// the first match from this list wins. If there is specifically `password` field
// in the subform, the schema will be completely replaced with the auto-generated
// password schema.
// Do not modify this object directly, use
// `self.apos.settings.addProtectedField(fieldName, protectedType)` instead.
self.systemProtectedFields = {
password: self.protectedTypes.password
// TODO phase 3
// username: self.protectedTypes.password,
// email: self.protectedTypes.email
};
// Completely forbidden fields, they are not allowed in the subforms.
// Do not modify this array directly, use
// `self.apos.settings.addForbiddenField(fieldName)` instead.
self.forbiddenFields = [
'role',
'disabled',
// TODO remove in phase 3
'username',
'email'
];
self.userSchema = [];
self.subforms = [];
self.initSubforms();
Expand All @@ -28,6 +61,37 @@ module.exports = {

methods(self) {
return {
// Public API method.
// Add a protected field to the system protected fields list.
// Modules can add their own protected fields here
// via 'apostrophe:modulesRegistered' event handler:
// ```js
// self.apos.settings.addProtectedField('myField', true);
// self.apos.settings.addProtectedField('myField', 'password');
// self.apos.settings.addProtectedField('myField', 'email');
// ```
addProtectedField(fieldName, protectedType) {
if (!self.protectedTypes[protectedType]) {
throw new Error(
`[@apostrophecms/settings] Attempt to add a protected field "${fieldName}" with invalid protected type "${protectedType}".`
);
}
if (!self.systemProtectedFields[fieldName]) {
self.systemProtectedFields[fieldName] = self.protectedTypes[protectedType];
}
},

// Public API method.
// Add a forbidden field to the forbidden fields list.
// Modules can add their own forbidden fields here
// via 'apostrophe:modulesRegistered' event handler:
// `self.apos.settings.addForbiddenField('myField');`
addForbiddenField(fieldName) {
if (!self.forbiddenFields.includes(fieldName)) {
self.forbiddenFields.push(fieldName);
}
},

hasSchema() {
return self.userSchema.length > 0;
},
Expand All @@ -41,6 +105,15 @@ module.exports = {
if (!Array.isArray(config.fields) || config.fields.length === 0) {
throw new Error(`[@apostrophecms/settings] The subform "${name}" must have at least one field.`);
}
// Don't allow malformed subform.protected.
if (config.protected && !self.protectedTypes[config.protected]) {
throw new Error(`[@apostrophecms/settings] The protected type "${config.protected}" is not valid.`);
}
if (config.protected) {
config.protected = self.protectedTypes[config.protected];
}
// No one is allowed to set the flag but us.
delete config._passwordChangeForm;
const schema = self.getSubformSchema(name);

self.subforms.push({
Expand Down Expand Up @@ -100,7 +173,7 @@ module.exports = {
}
});
}
// Push the leftover to ungrouped
// Push the leftover to ungrouped. It shouldn't be possible though.
const leftover = subforms
.filter(subform =>
!newSubforms.some(newSubform => newSubform.name === subform.name)
Expand All @@ -123,6 +196,10 @@ module.exports = {
...new Set(
Object.keys(subforms)
.reduce((acc, subform) => {
// Do not allow password field alongside other fields in a subform
if (subforms[subform].fields.includes('password')) {
subforms[subform].fields = [ 'password' ];
}
return acc.concat(subforms[subform].fields || []);
}, [])
)
Expand All @@ -142,29 +219,110 @@ module.exports = {

// Validate that the fields configured in the settings module exist in the
// user schema and are not forbidden.
// XXX Temporary (Phase 2,3) extend the forbidden protected fields.
validateSettingsSchema(settingsFieldNames, userSchema) {
const forbiddenFields = [
'role',
// These will be allowed in Phase 2 and 3
'username',
'password',
'email'
];
for (const name of settingsFieldNames) {
if (!userSchema.some(field => field.name === name)) {
throw new Error(`[@apostrophecms/settings] The field "${name}" is not a valid user field.`);
}
if (forbiddenFields.includes(name)) {
if (self.forbiddenFields.includes(name)) {
throw new Error(`[@apostrophecms/settings] The field "${name}" is forbidden.`);
}
}
},

// Enhance the subforms with additional information.
// This method requires initialized self.subforms and self.userSchema.
// Enhance the subforms - `protected` security.
// This method requires initialized self.subforms.
enhanceSubforms() {
// FIXME Not implemented
// 1. Add protected flag to subforms for system protected fields.
for (const [ fieldName, protectedType ] of Object.entries(self.systemProtectedFields)) {
self.subforms = self.subforms.map(subform => {
if (subform.fields.includes(fieldName)) {
subform.protected = protectedType || true;
}
return subform;
});
}

// 2. Ehhance the protected forms schema.
self.subforms = self.subforms.map(subform => {
if (!subform.protected) {
return subform;
}

// 2.1. Special case for the change password subform
const passwordField = subform.schema.find(field => field.name === 'password');
if (passwordField) {
self.enhancePasswordSubform(passwordField, subform);
return subform;
}

// 2.2. General case for all other protected subforms
self.enhanceProtectedSubform(subform);
return subform;
});
},

// Auto-generate and replace the subform schema for the "password change"
// scenario.
enhancePasswordSubform(passwordField, subform) {
const templateField = self.getPasswordTemplateField();
subform.help = subform.help || 'apostrophe:passwordChangeHelp';
if (!subform.label) {
subform.label = 'apostrophe:password';
}
subform.schema = [];
// Indicates the edge case of password change form
subform._passwordChangeForm = true;
subform.schema.push({
...passwordField,
label: 'apostrophe:passwordNew',
required: true
});
subform.schema.push({
...templateField,
label: 'apostrophe:passwordRepeat',
name: 'passwordRepeat',
required: true
});
subform.schema.push({
...templateField,
label: 'apostrophe:passwordCurrent',
name: 'passwordCurrent',
required: true
});
},

// Enhance the protected subform schema based on the protected type.
enhanceProtectedSubform(subform) {
switch (subform.protected) {
case self.protectedTypes.password: {
// Last field so that it doesn't mess up with the "first field label"
// detection on the client side (when form label is not specified).
subform.schema.push({
...self.getPasswordTemplateField(),
label: 'apostrophe:passwordCurrent',
name: 'passwordCurrent',
required: true
});
break;
}
// TODO `self.protectedTypes.email' in phase 3

default: {
throw new Error(`[@apostrophecms/settings] Not supported protected type "${subform.protected}".`);
}
}
},

// Clone the password field from the user schema to be used as a template
// for auto generated subform schema.
getPasswordTemplateField() {
const templateField = klona(self.apos.user.schema.find(field => field.name === 'password'));
delete templateField.moduleName;
delete templateField.group;
delete templateField.name;
delete templateField.label;
return templateField;
},

// Get subform fields by subform name.
Expand All @@ -183,6 +341,57 @@ module.exports = {
return self.subforms.find(subform => subform.name === name);
},

// Detect protected subforms and handle them.
handleProtectedSubform(req, subform, payload) {
if (!subform.protected) {
return;
}
if (subform._passwordChangeForm) {
return self.handlePasswordChangeSubform(req, subform, payload);
}
switch (subform.protected) {
case self.protectedTypes.password: {
return self.handlePasswordProtectedSubform(req, subform, payload);
}
// TODO `self.protectedTypes.email' in phase 3

// Should not happen as we validate the protected type in the init phase.
default: {
throw self.apos.error('invalid', `Not supported protected type "${subform.protected}".`);
}
}
},

// Handle the password change subform.
handlePasswordChangeSubform(req, subform, payload) {
const { password, passwordRepeat } = payload;
if (!password || passwordRepeat !== password) {
const invalid = self.apos.error('invalid', {
errors: 'invalid'
});
invalid.path = 'passwordRepeat';
throw [ invalid ];
}

return self.handlePasswordProtectedSubform(req, subform, payload);
},

// Handle the password protected subform.
async handlePasswordProtectedSubform(req, subform, payload) {
try {
await self.apos.user.verifyPassword(req.user, payload.passwordCurrent);
} catch (e) {
throw self.apos.error(
'forbidden',
'apostrophe:passwordCurrentError',
{
path: 'passwordCurrent'
}
);
}
return subform;
},

addToAdminBar() {
if (!self.hasSchema()) {
return;
Expand Down Expand Up @@ -250,14 +459,20 @@ module.exports = {
if (!self.hasSchema() || !req.user) {
throw self.apos.error('notfound');
}
const subform = self.getSubform(
let subform = self.getSubform(
self.apos.launder.string(req.params.subform)
);

if (!subform || !subform.schema.length) {
throw self.apos.error('notfound');
}

await self.handleProtectedSubform(req, subform, req.body);
// Remove the auto-generated fields from the schema
subform = klona(subform);
subform.schema = subform.schema
.filter(field => self.userSchema.some(userField => userField.name === field.name));

const user = await self.apos.user
.find(req, { _id: req.user._id })
.permission(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ function inferFieldValues(schema, values, $t) {
&__value {
@include type-large;
line-height: 1;
color: var(--a-base-1);

> span {
display: inline-block;
Expand Down
Loading

0 comments on commit c4512ba

Please sign in to comment.