- Honey batch create omits an empty batch number (was 400); product form
labels the legacy total weight, detail shows jar weight and shelf life;
bottling hints next to their fields, values cleared on product change,
post-save water-content wording; withdraw reason placeholder not
selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
nicht."); backup import gives its disabled reason; missing event labels
plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
without step-up).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
62 lines
2.0 KiB
TypeScript
62 lines
2.0 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { AccountGuardService } from '../mfa/account-guard.service';
|
|
|
|
export interface ReauthProof {
|
|
password?: string;
|
|
mfaCode?: string;
|
|
}
|
|
|
|
/**
|
|
* Step-up check for actions that are as powerful as the account itself
|
|
* (restoring a backup overwrites every user): a stolen access token alone
|
|
* must not be enough, so the caller proves they know the password - and,
|
|
* with MFA on, holds the second factor - right now.
|
|
*/
|
|
@Injectable()
|
|
export class ReauthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly accountGuard: AccountGuardService,
|
|
) {}
|
|
|
|
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
passwordHash: true,
|
|
mfaEnabled: true,
|
|
mfaSecret: true,
|
|
mfaLastTimeStep: true,
|
|
authLockedUntil: true,
|
|
isActive: true,
|
|
},
|
|
});
|
|
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
|
throw new UnauthorizedException(
|
|
'Zu viele Fehlversuche. Bestätigen ist für 15 Minuten gesperrt.',
|
|
);
|
|
}
|
|
const passwordMatches =
|
|
typeof proof.password === 'string' &&
|
|
(await bcrypt.compare(proof.password, user.passwordHash));
|
|
if (!passwordMatches) {
|
|
await this.accountGuard.recordFailure(user.id);
|
|
throw new UnauthorizedException('Das Passwort stimmt nicht.');
|
|
}
|
|
if (user.mfaEnabled) {
|
|
const codeMatches =
|
|
typeof proof.mfaCode === 'string' &&
|
|
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
|
if (!codeMatches) {
|
|
await this.accountGuard.recordFailure(user.id);
|
|
throw new UnauthorizedException('Der Code stimmt nicht.');
|
|
}
|
|
}
|
|
await this.accountGuard.recordSuccess(user.id);
|
|
}
|
|
}
|