fix(auth): reject a weak claim password before sending the code (#1648)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-15 20:53:03 +02:00
1 parent ae11ee86aa
commit e0876d719c
6 files changed
+30 -8

No files matched your search

+2 -1
View File
@@ -35114,7 +35114,8 @@
"properties": { "properties": {
"ticket": {"type": "string", "description": "Email change ticket identifier"}, "ticket": {"type": "string", "description": "Email change ticket identifier"},
"new_email": {"$ref": "#/components/schemas/EmailType"}, "new_email": {"$ref": "#/components/schemas/EmailType"},
"original_proof": {"type": "string", "description": "Proof token obtained from verifying the original email"} "original_proof": {"type": "string", "description": "Proof token obtained from verifying the original email"},
"new_password": {"$ref": "#/components/schemas/PasswordType"}
}, },
"required": ["ticket", "new_email", "original_proof"] "required": ["ticket", "new_email", "original_proof"]
}, },
@@ -224,7 +224,7 @@ export function UserAccountController(app: HonoApp) {
const body = ctx.req.valid('json'); const body = ctx.req.valid('json');
const result = await ctx const result = await ctx
.get('emailChangeService') .get('emailChangeService')
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof); .requestNewEmail(user, body.ticket, body.new_email, body.original_proof, body.new_password);
return ctx.json(result); return ctx.json(result);
}, },
); );
@@ -13,6 +13,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {ms} from 'itty-time'; import {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext'; import type {ApiContext} from '../../ApiContext';
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '../../auth/AuthEmail'; import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '../../auth/AuthEmail';
import * as AuthPassword from '../../auth/AuthPassword';
import type {User} from '../../models/User'; import type {User} from '../../models/User';
import type {EmailChangeRepository} from '../repositories/auth/EmailChangeRepository'; import type {EmailChangeRepository} from '../repositories/auth/EmailChangeRepository';
@@ -158,6 +159,7 @@ export class EmailChangeService {
ticket: string, ticket: string,
newEmail: string, newEmail: string,
originalProof: string, originalProof: string,
newPassword?: string,
): Promise<RequestNewEmailResult> { ): Promise<RequestNewEmailResult> {
const {email, emailDnsValidation, users, rateLimit} = this.apiContext.services; const {email, emailDnsValidation, users, rateLimit} = this.apiContext.services;
const row = await getActiveChangeTicketForUser(this.repo, ticket, user.id); const row = await getActiveChangeTicketForUser(this.repo, ticket, user.id);
@@ -182,6 +184,9 @@ export class EmailChangeService {
if (existing && existing.id !== user.id) { if (existing && existing.id !== user.id) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE); throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
} }
if (newPassword != null && (await AuthPassword.isPasswordPwned(this.apiContext, newPassword))) {
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
assertChangeCooldown(row.new_code_sent_at, this.RESEND_COOLDOWN_MS); assertChangeCooldown(row.new_code_sent_at, this.RESEND_COOLDOWN_MS);
await checkChangeRateLimit(rateLimit, { await checkChangeRateLimit(rateLimit, {
identifier: `email_change:new:${user.id}`, identifier: `email_change:new:${user.id}`,
@@ -60,6 +60,7 @@ export const ClaimAccountModal = observer(() => {
const [stage, setStage] = useState<Stage>('collect'); const [stage, setStage] = useState<Stage>('collect');
const [ticket, setTicket] = useState<string | null>(null); const [ticket, setTicket] = useState<string | null>(null);
const [originalProof, setOriginalProof] = useState<string | null>(null); const [originalProof, setOriginalProof] = useState<string | null>(null);
const [emailToken, setEmailToken] = useState<string | null>(null);
const [resendNewAt, setResendNewAt] = useState<Date | null>(null); const [resendNewAt, setResendNewAt] = useState<Date | null>(null);
const [submittingAction, setSubmittingAction] = useState<boolean>(false); const [submittingAction, setSubmittingAction] = useState<boolean>(false);
const [now, setNow] = useState<number>(Date.now()); const [now, setNow] = useState<number>(Date.now());
@@ -88,7 +89,7 @@ export const ClaimAccountModal = observer(() => {
if (!activeProof) { if (!activeProof) {
throw new Error('Missing original proof token'); throw new Error('Missing original proof token');
} }
const result = await UserCommands.requestEmailChangeNew(activeTicket!, data.email, activeProof); const result = await UserCommands.requestEmailChangeNew(activeTicket!, data.email, activeProof, data.newPassword);
setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null); setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null);
form.setValue('verificationCode', ''); form.setValue('verificationCode', '');
form.clearErrors('verificationCode'); form.clearErrors('verificationCode');
@@ -99,9 +100,14 @@ export const ClaimAccountModal = observer(() => {
if (!ticket || !originalProof) return; if (!ticket || !originalProof) return;
setSubmittingAction(true); setSubmittingAction(true);
try { try {
const {email_token} = await UserCommands.verifyEmailChangeNew(ticket, data.verificationCode, originalProof); let activeEmailToken = emailToken;
if (!activeEmailToken) {
const verified = await UserCommands.verifyEmailChangeNew(ticket, data.verificationCode, originalProof);
activeEmailToken = verified.email_token;
setEmailToken(activeEmailToken);
}
await UserCommands.update({ await UserCommands.update({
email_token, email_token: activeEmailToken,
new_password: data.newPassword, new_password: data.newPassword,
}); });
ToastCommands.createToast({type: 'success', children: i18n._(ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR)}); ToastCommands.createToast({type: 'success', children: i18n._(ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR)});
@@ -158,8 +158,14 @@ function requestNewEmailBody(
ticket: string, ticket: string,
newEmail: string, newEmail: string,
originalProof: string, originalProof: string,
): {ticket: string; new_email: string; original_proof: string} { newPassword?: string,
return {ticket, new_email: newEmail, original_proof: originalProof}; ): {ticket: string; new_email: string; original_proof: string; new_password?: string} {
return {
ticket,
new_email: newEmail,
original_proof: originalProof,
...(newPassword != null ? {new_password: newPassword} : {}),
};
} }
function verifyNewEmailBody( function verifyNewEmailBody(
@@ -365,11 +371,12 @@ export async function requestEmailChangeNew(
ticket: string, ticket: string,
newEmail: string, newEmail: string,
originalProof: string, originalProof: string,
newPassword?: string,
): Promise<EmailChangeRequestNewResponse> { ): Promise<EmailChangeRequestNewResponse> {
try { try {
logger.debug('Requesting new email code'); logger.debug('Requesting new email code');
const response = await http.post<EmailChangeRequestNewResponse>(Endpoints.USER_EMAIL_CHANGE_REQUEST_NEW, { const response = await http.post<EmailChangeRequestNewResponse>(Endpoints.USER_EMAIL_CHANGE_REQUEST_NEW, {
body: requestNewEmailBody(ticket, newEmail, originalProof), body: requestNewEmailBody(ticket, newEmail, originalProof, newPassword),
}); });
return response.body; return response.body;
} catch (error) { } catch (error) {
@@ -136,6 +136,9 @@ export type EmailChangeVerifyOriginalRequest = z.infer<typeof EmailChangeVerifyO
export const EmailChangeRequestNewRequest = EmailChangeTicketRequest.extend({ export const EmailChangeRequestNewRequest = EmailChangeTicketRequest.extend({
new_email: EmailType.describe('New email address to switch to'), new_email: EmailType.describe('New email address to switch to'),
original_proof: createStringType().describe('Proof token obtained from verifying the original email'), original_proof: createStringType().describe('Proof token obtained from verifying the original email'),
new_password: PasswordType.optional().describe(
'Password the caller intends to set, rejected here instead of after the code is sent',
),
}); });
export type EmailChangeRequestNewRequest = z.infer<typeof EmailChangeRequestNewRequest>; export type EmailChangeRequestNewRequest = z.infer<typeof EmailChangeRequestNewRequest>;