Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/gh 351 passwordreset with proof #352

Merged
merged 3 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/common/modules/auth/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class Auth {
return null;
}
throw err;
}).catch(err => resp.text().then(
}, err => resp.text().then(
text => {
throw new Err('auth.failedToAuthenticateMsg', "Failed to authenticate: {text}", resp.text());
},
Expand Down
21 changes: 21 additions & 0 deletions src/common/utils/responseParseError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Err from 'classes/Err';

/**
* Creates a response parse error callback function for when calling
* response.json() on a fetch response.
*
* Depending on status code being => 400, a different error message is shown.
* @param {Response} resp Response object.
* @returns {() => Promise} Promise that rejects with a value of type Err.
*/
export default function responseParseError(resp) {
return () => resp.status >= 400
? Promise.reject(new Err('responseParseError.errorStatus', "Failed with status {status} {statusText}.", { status: resp.status, statusText: resp.statusText }))
: resp.text().then(
text => Promise.reject(text
? new Err('responseParseError.unexptedResponse', "Unexpected response: {text}", { text })
: new Err('responseParseError.missingResponse', "Unexpected empty response.", { text }),
),
() => Promise.reject(new Err('responseParseError.unexpectedError', "Unexpected response {status} {statusText}.", { status: resp.status, statusText: resp.statusText })),
);
}
20 changes: 9 additions & 11 deletions src/hub/login/modules/init/login/Login.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Err from 'classes/Err';
import sha256, { hmacsha256, publicPepper } from 'utils/sha256';
import reload, { redirect } from 'utils/reload';
import { uri } from 'modapp-utils';
import responseParseError from 'utils/responseParseError';
import LoginComponent from './LoginComponent';
import LoginAgreeTerms from './LoginAgreeTerms';
import './login.scss';
Expand Down Expand Up @@ -70,7 +70,7 @@ class Login {
return;
}
throw err;
});
}, responseParseError(resp));
}

return resp.json().then(user => {
Expand All @@ -79,7 +79,7 @@ class Login {
} else {
this._redirect(true);
}
});
}, responseParseError(resp));
});
}

Expand All @@ -98,7 +98,7 @@ class Login {
if (resp.status >= 400) {
return resp.json().then(err => {
throw err;
});
}, responseParseError(resp));
}
this._redirect();
});
Expand Down Expand Up @@ -145,7 +145,7 @@ class Login {
if (resp.status >= 400) {
return resp.json().then(err => {
throw err;
});
}, responseParseError(resp));
}
this._redirect();
});
Expand All @@ -168,7 +168,7 @@ class Login {
return;
}
throw err;
});
}, responseParseError(resp));
}
return this._redirect();
});
Expand Down Expand Up @@ -196,11 +196,9 @@ class Login {
credentials: crossOrigin ? 'include' : 'same-origin',
}).then(resp => {
if (resp.status >= 400) {
return resp.json()
.catch(() => new Err('login.errorResponse', "Request returned with status {status}.", { status: resp.status }))
.then(err => {
throw err;
});
return resp.json().then(err => {
throw err;
}, responseParseError(resp));
}
});
}
Expand Down
6 changes: 0 additions & 6 deletions src/hub/login/modules/init/login/login.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,6 @@
}
}

&--disclaimer {
margin-top: 12px;
font-size: 13px;
font-style: italic;
}

&--forgotpass {
margin: -8px 0 6px 0;
font-family: $font-text;
Expand Down
63 changes: 47 additions & 16 deletions src/hub/reset/modules/init/passwordReset/PasswordReset.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import sha256, { hmacsha256, publicPepper } from 'utils/sha256';
import { redirect } from 'utils/reload';
import ErrorScreenDialog from 'components/ErrorScreenDialog';
import ConfirmScreenDialog from 'components/ConfirmScreenDialog';
import responseParseError from 'utils/responseParseError';
import PasswordResetComponent from './PasswordResetComponent';
import './passwordReset.scss';

Expand Down Expand Up @@ -42,7 +43,7 @@ class PasswordReset {
this.validateCode(this.code)
.then(result => {
if (result.isValid) {
this._showPasswordReset(this.code);
this._showPasswordReset(this.code, result.requireProof);
} else {
this._showInvalidCode();
}
Expand All @@ -54,7 +55,7 @@ class PasswordReset {
/**
* Validates the reset code.
* @param {string} code Code to verify.
* @returns {Promise} Promise of the code being verified.
* @returns {Promise<{isValid: boolean, requireProof: boolean}} Promise of the code being verified.
*/
validateCode(code) {
let formData = new FormData();
Expand All @@ -69,19 +70,37 @@ class PasswordReset {
if (resp.status >= 400) {
return resp.json().then(err => {
throw err;
}).catch(err => {
throw new Err('passwordReset.failedToVerifyCode', "Code verification failed with status {status}.", { status: resp.status });
});
}, responseParseError(resp));
}
return resp.json();
return resp.json().catch(responseParseError(resp));
});
}

resetPassword(code, pass) {
/**
* Sends a resetPassword request to the server.
* @param {string} code Reset ticket code.
* @param {string} pass New password
* @param {object} [opt] Optional parameters
* @param {string} [opt.username] Username as proof.
* @param {string} [opt.realm] Realm name as proof.
* @param {string} [opt.charName] Char name as proof.
* @returns Promise
*/
resetPassword(code, pass, opt) {
let formData = new FormData();
formData.append('code', code);
formData.append('pass', sha256(pass.trim()));
formData.append('hash', hmacsha256(pass.trim(), publicPepper));
if (opt?.username) {
formData.append('username', opt?.username.trim());
} else {
if (opt?.realm) {
formData.append('realm', opt?.realm.trim());
}
if (opt?.charName) {
formData.append('charName', opt?.charName.trim());
}
}

return fetch(resetUrl, {
body: formData,
Expand All @@ -92,24 +111,36 @@ class PasswordReset {
if (resp.status >= 400) {
return resp.json().then(err => {
throw err;
}).catch(err => {
throw new Err('passwordReset.resetFailed', "Reset failed with status {status}.", { status: resp.status });
});
}, responseParseError(resp));
}
this._showResetComplete();
return resp.json()
.then(result => this._showResetComplete(result.username, result.emailVerified))
.catch(() => this._showResetComplete());
});
}

_showPasswordReset(code) {
this.module.screen.setComponent(new PasswordResetComponent(this.module, code, this.state, this.params));
_showPasswordReset(code, requireProof) {
this.module.screen.setComponent(new PasswordResetComponent(this.module, code, requireProof, this.state, this.params));
}

_showResetComplete(email) {
_showResetComplete(username, emailVerified) {
this.module.screen.setComponent(new ConfirmScreenDialog({
title: l10n.l('passwordReset.passwordSuccessfullyReset', "Password reset completed"),
body: new Elem(n => n.elem('div', [
n.component(new Txt(l10n.l('passwordReset.passwordUpdateInfo1', "Your password has been updated."), { tagName: 'p' })),
n.component(new Txt(l10n.l('passwordReset.passwordUpdateInfo2', "Go to the login and try it out!"), { tagName: 'p' })),
n.component(new Txt(
emailVerified
? l10n.l('passwordReset.passwordUpdatedAndVerified', "Your password has been updated, and your email has been verified.")
: l10n.l('passwordReset.passwordUpdated', "Your password has been updated."),
{ tagName: 'p' },
)),
n.component(username
? new Txt(l10n.l('passwordReset.goToLoginWithAccount', "Go to the login and use it together with your account name:"), { tagName: 'p' })
: new Txt(l10n.l('passwordReset.goToLoginAndTry', "Go to the login and try it out!"), { tagName: 'p' }),
),
n.component(username
? new Txt(username, { tagName: 'p', className: 'passwordreset--username' })
: null,
),
])),
onConfirm: () => this._redirect(),
}));
Expand Down
79 changes: 49 additions & 30 deletions src/hub/reset/modules/init/passwordReset/PasswordResetComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,66 @@ import l10n from 'modapp-l10n';
import Collapser from 'components/Collapser';
import PasswordInput from 'components/PasswordInput';
import ScreenDialog from 'components/ScreenDialog';
import PasswordResetProof from './PasswordResetProof';

/**
* PasswordReset draws the password reset screen.
*/
class PasswordResetComponent {
constructor(module, code, state, opt) {
constructor(module, code, requireProof, state, opt) {
opt = opt || {};
this.module = module;
this.code = code;
this.requireProof = requireProof;
this.state = state;
state.reset = Object.assign({
username: '',
realm: '',
charName: '',
pass: '',
}, state.reset);
this.autoLogin = !!opt.hasOwnProperty('auto');
this.realm = opt.realm || null;
}

render(el) {
this.model = new Model({ data: this.state.reset, eventBus: this.module.self.app.eventBus });

this.elem = new ScreenDialog(new Elem(n => n.elem('div', { className: 'passwordreset' }, [
n.component(this.requireProof
? new PasswordResetProof(this.model, this.realm)
: null,
),
n.component(new Txt(
l10n.l('resetPassword.newPasswordDisclaimer', "Enter a new password for the account."),
{ tagName: 'div', className: 'passwordreset--disclaimer' },
)),
n.elem('label', { className: 'flex-1', attributes: { for: 'password' }}, [
n.elem('h3', [
n.component(new Txt(l10n.l('login.newPassword', "New password"))),
n.component(new Txt(" *", { className: 'common--error' })),
]),
]),
n.component('password', new PasswordInput(this.model.pass, {
n.component(new PasswordInput(this.model.pass, {
className: 'common--formmargin',
inputOpt: { attributes: { name: 'password', id: 'password' }},
inputOpt: { attributes: {
name: 'password',
id: 'password',
autocomplete: 'new-password',
}},
onInput: c => this.model.set({ pass: c.getValue() }),
})),
n.component('message', new Collapser(null)),
n.component('submit', new ModelComponent(
this.model,
new Elem(n => n.elem('button', {
events: { click: () => this._onReset(this.model) },
className: 'btn large primary passwordreset--login pad-top-xl passwordreset--btn',
className: 'btn large primary passwordreset--reset pad-top-xl passwordreset--btn',
}, [
n.elem('spinner', 'div', { className: 'spinner spinner--btn fade hide' }),
n.component(new Txt(l10n.l('passwordReset.resetPassword', "Reset password"))),
])),
(m, c) => c.setProperty('disabled', m && m.pass.trim().length >= 4 ? null : 'disabled'),
(m, c) => c.setProperty('disabled', this._isComplete(m) ? null : 'disabled'),
)),
])), {
title: l10n.l('passwordReset.resetPassword', "Reset password"),
Expand All @@ -56,46 +74,33 @@ class PasswordResetComponent {

unrender() {
if (this.elem) {
this.state.reset = this._getState();
this.state.reset = Object.assign({}, this.model?.props);
this.elem.unrender();
this.elem = null;
}
}

_getState() {
let c = this.elem.getComponent();
return {
password: c.getNode('password').getValue(),
};
}

_clearState() {
if (this.elem) {
let c = this.elem.getComponent();
c.getNode('password').setValue('');
}
this.state.reset = { password: '' };
}

_setSpinner(show) {
if (this.elem) {
let cl = this.elem.getComponent().getNode('submit').getComponent().getNode('spinner').classList;
cl[show ? 'remove' : 'add']('hide');
}
}

_onReset() {
if (!this.elem || this.resetPromise) return;
_onReset(m) {
if (this.resetPromise) return;

let { password } = this._getState();
this._setSpinner(true);

this.resetPromise = this.module.self.resetPassword(this.code, password)
.catch(err => {
this.resetPromise = null;
this._setSpinner(false);
this._setMessage(l10n.l(err.code, err.message, err.data));
});
this.resetPromise = this.module.self.resetPassword(this.code, m.pass, this.requireProof && {
username: m.username,
realm: m.realm,
charName: m.charName,
}).catch(err => {
this.resetPromise = null;
this._setSpinner(false);
this._setMessage(l10n.l(err.code, err.message, err.data));
});
}

_setMessage(msg) {
Expand All @@ -104,6 +109,20 @@ class PasswordResetComponent {
n.setComponent(msg ? new Txt(msg, { className: 'passwordreset--message' }) : null);
}
}

// Check if the form is filled out completely
_isComplete(m) {
return m && m.pass.trim().length >= 4 &&
(
!this.requireProof || (
// Proof by username
m.username?.trim().length >= 4
) || (
// Proof by realm and char name
m.realm?.length >= 3 && m.charName.trim().match(/^[^\s]+\s+[^\s]/)
)
);
}
}

export default PasswordResetComponent;
Loading