Unverified Commit c6d6e908 authored by Kourser's avatar Kourser
Browse files

Mon compte : changer son nom, son mot de passe et son adresse

L'écran ne portait que le second facteur. Passer par un administrateur
pour corriger l'orthographe de son propre nom, ou changer un mot de
passe qu'on croit compromis, n'a pas de sens.

Le nom se change sans rien prouver : il n'ouvre aucun accès. Le mot de
passe et l'adresse exigent le mot de passe actuel — sans quoi un poste
laissé ouvert cinq minutes suffit à s'approprier le compte pour de bon.
Ces deux gestes révoquent les sessions et en reposent une neuve pour
l'intéressé, faute de quoi son propre changement le déconnecterait.

L'adresse est l'identifiant de connexion : les liens en attente vers
l'ancienne sont annulés, sinon qui contrôle l'ancienne boîte garderait
un moyen d'agir sur le compte. La trace n'en souffre pas, le journal
d'instance copiant l'adresse de l'auteur au moment de l'écriture ;
l'ancienne est consignée, c'est elle qui relie l'entrée au passé du
compte.

RÉSERVE : la nouvelle adresse n'est pas vérifiée par un envoi. Une
faute de frappe ne se paie qu'au prochain mot de passe oublié. L'écran
le dit au-dessus du champ, faute de mieux pour l'instant.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1fedd72d
Loading
Loading
Loading
Loading
+52 −0
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import {
  Get,
  HttpCode,
  HttpStatus,
  Patch,
  Post,
  Req,
  Res,
@@ -28,6 +29,8 @@ import {
} from './auth.types';
import type { SessionUser } from './public-user';
import { SelectOrganisationDto } from './dto/select-organisation.dto';
import { ProfileService } from './profile.service';
import { ChangeEmailDto, ChangePasswordDto, RenameDto } from './dto/profile.dto';

const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000;
/** Le jeton d'attente du second facteur vit cinq minutes, comme sa signature. */
@@ -52,6 +55,7 @@ export class AuthController {
    private readonly auth: AuthService,
    private readonly mfa: MfaService,
    private readonly jwt: JwtService,
    private readonly profile: ProfileService,
  ) {}

  /**
@@ -175,6 +179,54 @@ export class AuthController {
    });
  }

  // --- Mon compte : ce que l'interesse change lui-meme -----------------------

  /** Nom affiche. Rien d'autre n'en depend : la session courante reste valable. */
  @Patch('me')
  @UseGuards(AuthGuard)
  async rename(@CurrentUser() me: AuthUser, @Body() dto: RenameDto): Promise<SessionUser> {
    await this.profile.rename(me.userId, dto.displayName.trim());
    return this.me(me);
  }

  /*
   * Mot de passe et adresse revoquent TOUTES les sessions du compte, celle-ci
   * comprise. Sans le jeton neuf pose ici, l'interesse serait deconnecte par son
   * propre changement — et croirait avoir echoue.
   *
   * PLAFONNEES toutes les deux : elles verifient un mot de passe, donc elles
   * offrent la meme prise qu'un formulaire de connexion a qui trouve un poste
   * ouvert.
   */

  @Post('me/password')
  @UseGuards(AuthGuard, RateLimitGuard)
  @RateLimit(10, 15 * 60_000)
  @HttpCode(HttpStatus.OK)
  async changePassword(
    @CurrentUser() me: AuthUser,
    @Body() dto: ChangePasswordDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<SessionUser> {
    await this.profile.changePassword(me.userId, dto.currentPassword, dto.newPassword);
    await this.reissue(me, res);
    return this.me(me);
  }

  @Post('me/email')
  @UseGuards(AuthGuard, RateLimitGuard)
  @RateLimit(10, 15 * 60_000)
  @HttpCode(HttpStatus.OK)
  async changeEmail(
    @CurrentUser() me: AuthUser,
    @Body() dto: ChangeEmailDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<SessionUser> {
    await this.profile.changeEmail(me.userId, dto.currentPassword, dto.email);
    await this.reissue(me, res);
    return this.me(me);
  }

  // --- Second facteur, sur une session ouverte ------------------------------

  /**
+2 −1
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ProfileService } from './profile.service';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './roles.guard';
import { RateLimitGuard } from '../common/rate-limit.guard';
@@ -36,7 +37,7 @@ function jwtSecret(): string {
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, AuthGuard, RolesGuard, RateLimitGuard, MfaService],
  providers: [AuthService, ProfileService, AuthGuard, RolesGuard, RateLimitGuard, MfaService],
  // AuthService est exporte : le module d'instance signe ses propres sessions
  // (bascule vers une organisation) et doit passer par le meme point unique.
  // `MfaService` est exporte : la console d'instance doit pouvoir REARMER le
+39 −0
Original line number Diff line number Diff line
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
import {
  PASSWORD_LOGIN_MAX_LENGTH,
  PASSWORD_MAX_LENGTH,
  PASSWORD_MIN_LENGTH,
} from '../password-policy';

export class RenameDto {
  @IsString()
  @MinLength(1)
  @MaxLength(120)
  displayName!: string;
}

export class ChangePasswordDto {
  /**
   * Le mot de passe ACTUEL n'est pas borne par la politique de definition mais par
   * celle de la connexion : un compte anterieur au durcissement doit pouvoir se
   * presenter avec le sien pour, justement, en changer.
   */
  @IsString()
  @MaxLength(PASSWORD_LOGIN_MAX_LENGTH)
  currentPassword!: string;

  @IsString()
  @MinLength(PASSWORD_MIN_LENGTH)
  @MaxLength(PASSWORD_MAX_LENGTH)
  newPassword!: string;
}

export class ChangeEmailDto {
  @IsString()
  @MaxLength(PASSWORD_LOGIN_MAX_LENGTH)
  currentPassword!: string;

  @IsEmail()
  @MaxLength(200)
  email!: string;
}
+103 −0
Original line number Diff line number Diff line
import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { compare, hash } from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../common/audit.service';
import { SessionEpochService } from '../common/session-epoch.service';

const BCRYPT_ROUNDS = 12;

/**
 * Ce qu'un compte peut changer CHEZ LUI, sans passer par un administrateur.
 *
 * La contrainte « une seule organisation » qui encadre les modifications faites
 * par un admin d'organisation ne s'applique pas ici : elle existe pour l'empecher
 * de prendre la main sur les acces de quelqu'un ailleurs. L'interesse, lui, est
 * partout la meme personne.
 */
@Injectable()
export class ProfileService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly audit: AuditService,
    private readonly epochs: SessionEpochService,
  ) {}

  /** Nom affiche. Sans confirmation : il n'ouvre aucun acces. */
  async rename(userId: string, displayName: string): Promise<void> {
    await this.prisma.user.update({ where: { id: userId }, data: { displayName } });
    this.audit.record('account.renamed', { userId });
  }

  /**
   * Changement de mot de passe.
   *
   * L'ANCIEN est exige : sans lui, un poste laisse ouvert quelques minutes suffit
   * a s'approprier le compte pour de bon. Les autres sessions tombent — c'est tout
   * l'objet du geste quand on le fait parce qu'on se croit compromis.
   */
  async changePassword(userId: string, current: string, next: string): Promise<void> {
    const user = await this.requireUser(userId);
    if (!(await compare(current, user.passwordHash))) {
      throw new UnauthorizedException('Mot de passe actuel incorrect');
    }
    if (await compare(next, user.passwordHash)) {
      throw new BadRequestException('Le nouveau mot de passe est identique à l’ancien');
    }
    await this.prisma.user.update({
      where: { id: userId },
      data: { passwordHash: await hash(next, BCRYPT_ROUNDS) },
    });
    await this.epochs.revokeAll(userId);
    this.audit.record('account.password_changed', { userId });
  }

  /**
   * Changement d'adresse de courriel — c'est-a-dire d'IDENTIFIANT de connexion.
   *
   * Le mot de passe est exige pour la meme raison que ci-dessus. Trois effets sont
   * traites explicitement :
   *
   * - l'unicite, qu'un doublon violerait en base de toute facon, refusee ici avec
   *   un message comprehensible ;
   * - les liens en attente vers l'ANCIENNE adresse (activation, mot de passe
   *   oublie, invitation), annules : sinon, qui controle l'ancienne boite garde un
   *   moyen d'agir sur le compte apres coup ;
   * - les sessions, revoquees, puisque l'identifiant qu'elles ont servi a ouvrir
   *   n'existe plus.
   *
   * La TRACE n'est pas affectee : le journal d'instance copie l'adresse de l'auteur
   * au moment de l'ecriture (`InstanceAudit.actorEmail`) a cote de son identifiant.
   * Les entrees passees gardent donc l'adresse d'alors, et rien n'est reecrit.
   */
  async changeEmail(userId: string, currentPassword: string, email: string): Promise<void> {
    const user = await this.requireUser(userId);
    if (!(await compare(currentPassword, user.passwordHash))) {
      throw new UnauthorizedException('Mot de passe incorrect');
    }
    const next = email.trim().toLowerCase();
    if (next === user.email.toLowerCase()) {
      throw new BadRequestException('Cette adresse est déjà celle du compte');
    }
    const clash = await this.prisma.user.findUnique({ where: { email: next } });
    if (clash) throw new ConflictException('Un compte utilise déjà cette adresse');

    await this.prisma.user.update({ where: { id: userId }, data: { email: next } });
    await this.prisma.authToken.deleteMany({ where: { email: user.email, usedAt: null } });
    await this.epochs.revokeAll(userId);
    // L'ANCIENNE adresse est consignee : c'est elle qui relie cette entree a tout
    // ce que le compte a fait avant, sous son nom d'alors.
    this.audit.record('account.email_changed', { userId, previousEmail: user.email });
  }

  private async requireUser(userId: string) {
    const user = await this.prisma.user.findUnique({ where: { id: userId } });
    if (!user) throw new NotFoundException('Compte introuvable');
    return user;
  }
}
+70 −0
Original line number Diff line number Diff line
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { hash } from 'bcryptjs';
import { ProfileService } from './profile.service';
import { PrismaService } from '../prisma/prisma.service';

describe('ProfileService', () => {
  const MOT_DE_PASSE = 'ChangeMoi123!';

  async function build(existant: { id: string; email: string } | null = null) {
    const user = {
      id: 'u1',
      email: 'admin@meridien.exercice',
      displayName: 'Admin',
      passwordHash: await hash(MOT_DE_PASSE, 4),
    };
    const prisma = {
      user: {
        findUnique: jest.fn(({ where }: { where: { id?: string; email?: string } }) =>
          Promise.resolve(where.id ? user : existant),
        ),
        update: jest.fn().mockResolvedValue(user),
      },
      authToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
    } as unknown as PrismaService;
    const audit = { record: jest.fn() };
    const epochs = { revokeAll: jest.fn().mockResolvedValue(undefined) };
    const service = new ProfileService(prisma, audit as never, epochs as never);
    return { service, prisma, audit, epochs };
  }

  it('refuse un changement de mot de passe sans le mot de passe actuel exact', async () => {
    const { service, epochs } = await build();
    await expect(service.changePassword('u1', 'pas-le-bon', 'NouveauMotDePasse1!')).rejects.toThrow(
      UnauthorizedException,
    );
    // Rien ne doit bouger sur un echec : ni le secret, ni les sessions.
    expect(epochs.revokeAll).not.toHaveBeenCalled();
  });

  it('ferme les autres sessions quand le mot de passe change', async () => {
    const { service, epochs } = await build();
    await service.changePassword('u1', MOT_DE_PASSE, 'NouveauMotDePasse1!');
    expect(epochs.revokeAll).toHaveBeenCalledWith('u1');
  });

  it('refuse une adresse deja portee par un autre compte', async () => {
    const { service } = await build({ id: 'u2', email: 'occupee@exemple.fr' });
    await expect(service.changeEmail('u1', MOT_DE_PASSE, 'occupee@exemple.fr')).rejects.toThrow(
      ConflictException,
    );
  });

  it("annule les liens en attente vers l'ancienne adresse", async () => {
    const { service, prisma } = await build();
    await service.changeEmail('u1', MOT_DE_PASSE, 'nouvelle@exemple.fr');
    // Sinon, qui controle l'ancienne boite garderait un moyen d'agir sur le compte.
    expect(prisma.authToken.deleteMany).toHaveBeenCalledWith({
      where: { email: 'admin@meridien.exercice', usedAt: null },
    });
  });

  it("consigne l'ancienne adresse, qui relie la trace au passe du compte", async () => {
    const { service, audit } = await build();
    await service.changeEmail('u1', MOT_DE_PASSE, 'nouvelle@exemple.fr');
    expect(audit.record).toHaveBeenCalledWith('account.email_changed', {
      userId: 'u1',
      previousEmail: 'admin@meridien.exercice',
    });
  });
});
Loading