feat(applications): publish encrypted private drafts (#148)
This commit is contained in:
1 parent
59d6e3d6e1
commit
06a8037ad9
62 files changed
+3163
-17
No files matched your search
@@ -92,9 +92,9 @@ flowchart TD
|
||||
Backend -->|Lecture / Écriture / Compression WebP| Storage
|
||||
```
|
||||
|
||||
### Chiffrement prévu des candidatures
|
||||
### Chiffrement des candidatures
|
||||
|
||||
Le mécanisme de chiffrement de bout en bout prévu par [l'ADR-0034](docs/adr/0034-encrypt-candidate-applications-outside-the-server.md) protège les données et justificatifs de candidature avant leur envoi au serveur :
|
||||
Le mécanisme de chiffrement de bout en bout défini par [l'ADR-0034](docs/adr/0034-encrypt-candidate-applications-outside-the-server.md) protège les données et justificatifs de candidature avant leur envoi au serveur :
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -113,14 +113,17 @@ sequenceDiagram
|
||||
|
||||
Note over S,C: Création et saisie d'un dossier
|
||||
C->>S: Ouvre le lien public du logement par un GET sans effet
|
||||
S-->>C: Charge l'application et la notice
|
||||
C->>S: Demande automatiquement la création par POST
|
||||
S-->>C: Fournit le brouillon, sa capacité d'accès et la clé publique
|
||||
C->>C: Génère la clé du dossier et place capacité + clé après #
|
||||
C->>C: Chiffre les champs et les PDF/JPEG/PNG par AES-256-GCM
|
||||
S-->>C: Fournit la notice et la clé publique du bailleur
|
||||
C->>C: Génère la capacité et la clé du dossier
|
||||
C->>C: Chiffre la clé du dossier par RSA-OAEP 3072 / SHA-256
|
||||
C->>S: Envoie seulement les chiffrés et utilise la capacité d'accès
|
||||
C->>S: POST avec la capacité en en-tête et la clé de dossier chiffrée
|
||||
S->>S: Ne conserve qu'une empreinte de la capacité
|
||||
S-->>C: Fournit l'identifiant du brouillon vide
|
||||
C->>C: Remplace l'URL par identifiant + capacité + clé après #
|
||||
|
||||
Note over S,C: Saisie ultérieure du dossier
|
||||
C->>C: Chiffre les champs et les PDF/JPEG/PNG par AES-256-GCM
|
||||
C->>S: Envoie seulement les chiffrés et utilise la capacité d'accès
|
||||
|
||||
Note over B,S: Lecture d'un dossier soumis
|
||||
B->>S: Demande le dossier avec sa session OIDC
|
||||
|
||||
+6
-4
@@ -26,15 +26,17 @@ public class SecurityConfiguration {
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityProperties properties,
|
||||
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService) throws Exception {
|
||||
http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/api/user").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasAuthority(properties.adminRole()).requestMatchers("/api/**")
|
||||
.authenticated().anyRequest().permitAll())
|
||||
http.authorizeHttpRequests(
|
||||
authorize -> authorize.requestMatchers("/api/user", "/api/public/**", "/api/candidate/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasAuthority(properties.adminRole())
|
||||
.requestMatchers("/api/**").authenticated().anyRequest().permitAll())
|
||||
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||
PathPatternRequestMatcher.pathPattern("/api/**")))
|
||||
.oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService))
|
||||
.defaultSuccessUrl("/", true))
|
||||
.logout(logout -> logout.logoutSuccessUrl("/")).csrf(csrf -> csrf.spa());
|
||||
.logout(logout -> logout.logoutSuccessUrl("/"))
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/public/**", "/api/candidate/**").spa());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties("lmnp.applications.creation-rate-limit")
|
||||
public record ApplicationCreationRateLimitProperties(int maximumAttempts, Duration window) {
|
||||
}
|
||||
+21
-1
@@ -39,10 +39,15 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimiter;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
|
||||
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.calendar.ManualEventRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
|
||||
@@ -69,7 +74,7 @@ import org.springframework.context.annotation.Profile;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties({E2eClockProperties.class, RentalAttachmentProperties.class,
|
||||
RentalStorageProperties.class,})
|
||||
RentalStorageProperties.class, ApplicationCreationRateLimitProperties.class})
|
||||
public class RentalApplicationConfiguration {
|
||||
private static final ZoneId PROPERTY_CIVIL_TIME_ZONE = ZoneId.of("Europe/Paris");
|
||||
|
||||
@@ -153,6 +158,21 @@ public class RentalApplicationConfiguration {
|
||||
return new TenantAttachmentService(tenants, storage, identifiers, properties.maxSize().toBytes());
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationCreationRateLimiter applicationCreationRateLimiter(ApplicationCreationRateLimitProperties properties,
|
||||
Clock rentalClock) {
|
||||
return new ApplicationCreationRateLimiter(properties.maximumAttempts(), properties.window(), rentalClock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationLinkService applicationLinkService(ApartmentRepository apartments, LandlordRepository landlords,
|
||||
ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
|
||||
RentalApplicationRepository applications, TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock rentalClock, ApplicationCreationRateLimiter rateLimiter) {
|
||||
return new ApplicationLinkService(apartments, landlords, vaults, links, applications, transactions, identifiers,
|
||||
rentalClock, rateLimiter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
LeaseDraftService leaseDraftService(ApartmentRepository apartments, TenantRepository tenants,
|
||||
LeaseRepository leases, TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@Component
|
||||
public class ApplicationSecurityHeadersFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (request.getRequestURI().startsWith("/api/application-links")
|
||||
|| request.getRequestURI().startsWith("/api/public/application-links")
|
||||
|| request.getRequestURI().startsWith("/api/candidate/applications")
|
||||
|| request.getRequestURI().startsWith("/api/rental-applications")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("Referrer-Policy", "no-referrer");
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/candidate/applications")
|
||||
public class CandidateApplicationController {
|
||||
private final ApplicationLinkService links;
|
||||
|
||||
public CandidateApplicationController(ApplicationLinkService links) {
|
||||
this.links = links;
|
||||
}
|
||||
|
||||
@GetMapping("/{applicationId}")
|
||||
RentalApplicationApi.CandidateApplicationResponse resume(@PathVariable String applicationId,
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization) {
|
||||
return RentalApplicationApi.CandidateApplicationResponse
|
||||
.from(links.resume(new RentalApplicationId(applicationId), authorization));
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/application-links/apartments")
|
||||
public class LandlordApplicationLinkController {
|
||||
private final ApplicationLinkService links;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
|
||||
public LandlordApplicationLinkController(ApplicationLinkService links,
|
||||
AuthenticatedLandlord authenticatedLandlord) {
|
||||
this.links = links;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
}
|
||||
|
||||
@GetMapping("/{apartmentId}")
|
||||
RentalApplicationApi.LinkStatusResponse status(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String apartmentId) {
|
||||
return RentalApplicationApi.LinkStatusResponse
|
||||
.from(links.status(authenticatedLandlord.id(principal), new ApartmentId(apartmentId)));
|
||||
}
|
||||
|
||||
@PostMapping("/{apartmentId}")
|
||||
RentalApplicationApi.LinkStatusResponse publish(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String apartmentId,
|
||||
@Valid @RequestBody(required = false) RentalApplicationApi.PublishRequest request) {
|
||||
ApplicationLinkService.VaultActivation activation = request == null || request.activation() == null
|
||||
? null
|
||||
: request.activation().toDomain();
|
||||
return RentalApplicationApi.LinkStatusResponse
|
||||
.from(links.publish(authenticatedLandlord.id(principal), new ApartmentId(apartmentId), activation));
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/public/application-links")
|
||||
public class PublicApplicationController {
|
||||
private final ApplicationLinkService links;
|
||||
|
||||
public PublicApplicationController(ApplicationLinkService links) {
|
||||
this.links = links;
|
||||
}
|
||||
|
||||
@GetMapping("/{token}")
|
||||
RentalApplicationApi.PublicLinkResponse preview(@PathVariable String token) {
|
||||
return RentalApplicationApi.PublicLinkResponse.from(links.preview(token));
|
||||
}
|
||||
|
||||
@PostMapping("/{token}/applications")
|
||||
ResponseEntity<RentalApplicationApi.CreatedDraftResponse> create(@PathVariable String token,
|
||||
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@Valid @RequestBody RentalApplicationApi.CreateDraftRequest request, HttpServletRequest servletRequest) {
|
||||
var application = links.createDraft(token, authorization, request.encryptedApplicationKey().toDomain(),
|
||||
servletRequest.getRemoteAddr());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(RentalApplicationApi.CreatedDraftResponse.from(application));
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.Instant;
|
||||
|
||||
final class RentalApplicationApi {
|
||||
private RentalApplicationApi() {
|
||||
}
|
||||
|
||||
record PublishRequest(@Valid VaultActivationRequest activation) {
|
||||
}
|
||||
|
||||
record VaultActivationRequest(@NotBlank @Size(max = 1400) String publicKey,
|
||||
@NotBlank @Size(max = 1400) String credentialId, @NotBlank @Size(max = 100) String label,
|
||||
@NotBlank @Size(max = 64) String prfSalt, @NotBlank @Size(max = 64) String hkdfSalt,
|
||||
@NotBlank @Size(max = 32) String nonce, @NotBlank @Size(max = 12000) String encryptedPrivateKey,
|
||||
@Min(1) @Max(1) int cryptoVersion) {
|
||||
ApplicationLinkService.VaultActivation toDomain() {
|
||||
return new ApplicationLinkService.VaultActivation(publicKey, credentialId, label, prfSalt, hkdfSalt, nonce,
|
||||
encryptedPrivateKey, cryptoVersion);
|
||||
}
|
||||
}
|
||||
|
||||
record LinkStatusResponse(boolean vaultActive, int passkeyCount, ActiveLinkResponse activeLink) {
|
||||
static LinkStatusResponse from(ApplicationLinkService.LinkStatus status) {
|
||||
return new LinkStatusResponse(status.vaultActive(), status.passkeyCount(),
|
||||
status.activeLink() == null
|
||||
? null
|
||||
: new ActiveLinkResponse(status.activeLink().token(),
|
||||
"/candidatures/postuler/" + status.activeLink().token(),
|
||||
status.activeLink().createdAt()));
|
||||
}
|
||||
}
|
||||
|
||||
record ActiveLinkResponse(String token, String path, Instant createdAt) {
|
||||
}
|
||||
|
||||
record PublicLinkResponse(String token, Instant createdAt, String landlordPublicKey, String landlordName,
|
||||
String landlordEmail, String apartmentName) {
|
||||
static PublicLinkResponse from(ApplicationLinkService.PublicLinkView view) {
|
||||
return new PublicLinkResponse(view.token(), view.createdAt(), view.landlordPublicKey(), view.landlordName(),
|
||||
view.landlordEmail(), view.apartmentName());
|
||||
}
|
||||
}
|
||||
|
||||
record CreateDraftRequest(@NotNull @Valid EncryptedApplicationKeyRequest encryptedApplicationKey) {
|
||||
}
|
||||
|
||||
record EncryptedApplicationKeyRequest(@Min(1) @Max(1) int cryptoVersion,
|
||||
@NotBlank @Size(max = 600) String ciphertext) {
|
||||
ApplicationLinkService.EncryptedApplicationKey toDomain() {
|
||||
return new ApplicationLinkService.EncryptedApplicationKey(cryptoVersion, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
record CreatedDraftResponse(String id, String state, int revision) {
|
||||
static CreatedDraftResponse from(RentalApplication application) {
|
||||
return new CreatedDraftResponse(application.id().value(), application.state().name(),
|
||||
application.revision());
|
||||
}
|
||||
}
|
||||
|
||||
record CandidateApplicationResponse(String id, String apartmentId, String state, int revision, Instant createdAt,
|
||||
Instant lastActivityAt, String encryptedApplicationKey, int cryptoVersion, String landlordName,
|
||||
String landlordEmail, String apartmentName) {
|
||||
static CandidateApplicationResponse from(ApplicationLinkService.CandidateApplicationView view) {
|
||||
RentalApplication application = view.application();
|
||||
return new CandidateApplicationResponse(application.id().value(), application.apartmentId().value(),
|
||||
application.state().name(), application.revision(), application.createdAt(),
|
||||
application.lastActivityAt(), application.encryptedApplicationKey(), application.cryptoVersion(),
|
||||
view.landlordName(), view.landlordEmail(), view.apartmentName());
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -1,6 +1,8 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimited;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.AcquisitionFactsCorrectionNotAllowed;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
|
||||
@@ -69,6 +71,18 @@ public class RentalApiExceptionHandler {
|
||||
.body(new CodedApiError("tenant_contract_profile_incomplete", exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ApplicationVaultRequired.class)
|
||||
ResponseEntity<CodedApiError> applicationVaultRequired(ApplicationVaultRequired exception) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new CodedApiError("application_vault_required", exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ApplicationCreationRateLimited.class)
|
||||
ResponseEntity<CodedApiError> applicationCreationRateLimited(ApplicationCreationRateLimited exception) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(new CodedApiError("application_creation_rate_limited", exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(FurnitureInventoryPrivateAreasMissing.class)
|
||||
ResponseEntity<FurnitureInventoryPrerequisiteError> furnitureInventoryPrivateAreasMissing(
|
||||
FurnitureInventoryPrivateAreasMissing exception) {
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class MongoApplicationVaultRepository implements ApplicationVaultRepository {
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
public MongoApplicationVaultRepository(MongoTemplate mongoTemplate) {
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ApplicationVault> find(LandlordId landlordId) {
|
||||
return Optional.ofNullable(mongoTemplate.findById(landlordId.value(), ApplicationVaultDocument.class))
|
||||
.map(MongoApplicationVaultRepository::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApplicationVault save(ApplicationVault vault) {
|
||||
ApplicationVaultDocument document = toDocument(vault);
|
||||
return toDomain(LandlordScopedMongoWriter.saveLandlord(mongoTemplate, document, document.landlordId(),
|
||||
vault.version(), document::withVersion));
|
||||
}
|
||||
|
||||
private static ApplicationVaultDocument toDocument(ApplicationVault vault) {
|
||||
return new ApplicationVaultDocument(vault.landlordId().value(), vault.publicKey(),
|
||||
vault.passkeys().stream().map(MongoApplicationVaultRepository::toDocument).toList(),
|
||||
persistedVersion(vault.version()));
|
||||
}
|
||||
|
||||
private static ApplicationPasskeyDocument toDocument(ApplicationPasskey passkey) {
|
||||
return new ApplicationPasskeyDocument(passkey.credentialId(), passkey.label(), passkey.prfSalt(),
|
||||
passkey.hkdfSalt(), passkey.nonce(), passkey.encryptedPrivateKey(), passkey.cryptoVersion(),
|
||||
passkey.createdAt(), passkey.lastUsedAt());
|
||||
}
|
||||
|
||||
private static ApplicationVault toDomain(ApplicationVaultDocument document) {
|
||||
return ApplicationVault.reconstitute(new LandlordId(document.landlordId()), document.publicKey(),
|
||||
document.passkeys().stream().map(MongoApplicationVaultRepository::toDomain).toList(),
|
||||
domainVersion(document.version()));
|
||||
}
|
||||
|
||||
private static ApplicationPasskey toDomain(ApplicationPasskeyDocument document) {
|
||||
return new ApplicationPasskey(document.credentialId(), document.label(), document.prfSalt(),
|
||||
document.hkdfSalt(), document.nonce(), document.encryptedPrivateKey(), document.cryptoVersion(),
|
||||
document.createdAt(), document.lastUsedAt());
|
||||
}
|
||||
|
||||
private static Long persistedVersion(long version) {
|
||||
return version < 0 ? null : version;
|
||||
}
|
||||
|
||||
private static long domainVersion(Long version) {
|
||||
return version == null ? -1 : version;
|
||||
}
|
||||
}
|
||||
|
||||
@Document("applicationVaults")
|
||||
record ApplicationVaultDocument(@Id String landlordId, String publicKey, List<ApplicationPasskeyDocument> passkeys,
|
||||
@Version Long version) {
|
||||
ApplicationVaultDocument withVersion(long updatedVersion) {
|
||||
return new ApplicationVaultDocument(landlordId, publicKey, passkeys, updatedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
record ApplicationPasskeyDocument(String credentialId, String label, String prfSalt, String hkdfSalt, String nonce,
|
||||
String encryptedPrivateKey, int cryptoVersion, Instant createdAt, Instant lastUsedAt) {
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.query.Criteria;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class MongoPublicApplicationLinkRepository implements PublicApplicationLinkRepository {
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
public MongoPublicApplicationLinkRepository(MongoTemplate mongoTemplate) {
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicApplicationLink> find(ApartmentId apartmentId) {
|
||||
return Optional.ofNullable(mongoTemplate.findById(apartmentId.value(), PublicApplicationLinkDocument.class))
|
||||
.map(MongoPublicApplicationLinkRepository::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PublicApplicationLink> findActiveByToken(String token) {
|
||||
Query query = Query.query(Criteria.where("token").is(token).and("closedAt").is(null));
|
||||
return Optional.ofNullable(mongoTemplate.findOne(query, PublicApplicationLinkDocument.class))
|
||||
.map(MongoPublicApplicationLinkRepository::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicApplicationLink save(PublicApplicationLink link) {
|
||||
PublicApplicationLinkDocument document = toDocument(link);
|
||||
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.apartmentId(),
|
||||
document.landlordId(), link.version(), document::withVersion));
|
||||
}
|
||||
|
||||
private static PublicApplicationLinkDocument toDocument(PublicApplicationLink link) {
|
||||
return new PublicApplicationLinkDocument(link.apartmentId().value(), link.landlordId().value(), link.token(),
|
||||
link.createdAt(), link.closedAt(), link.creationCount(), link.lastCreatedAt(),
|
||||
link.version() < 0 ? null : link.version());
|
||||
}
|
||||
|
||||
private static PublicApplicationLink toDomain(PublicApplicationLinkDocument document) {
|
||||
return PublicApplicationLink.reconstitute(new ApartmentId(document.apartmentId()),
|
||||
new LandlordId(document.landlordId()), document.token(), document.createdAt(), document.closedAt(),
|
||||
document.creationCount(), document.lastCreatedAt(),
|
||||
document.version() == null ? -1 : document.version());
|
||||
}
|
||||
}
|
||||
|
||||
@Document("publicApplicationLinks")
|
||||
record PublicApplicationLinkDocument(@Id String apartmentId, String landlordId, @Indexed(unique = true) String token,
|
||||
Instant createdAt, Instant closedAt, long creationCount, Instant lastCreatedAt, @Version Long version) {
|
||||
PublicApplicationLinkDocument withVersion(long updatedVersion) {
|
||||
return new PublicApplicationLinkDocument(apartmentId, landlordId, token, createdAt, closedAt, creationCount,
|
||||
lastCreatedAt, updatedVersion);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationState;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class MongoRentalApplicationRepository implements RentalApplicationRepository {
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
public MongoRentalApplicationRepository(MongoTemplate mongoTemplate) {
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RentalApplication> find(RentalApplicationId id) {
|
||||
return Optional.ofNullable(mongoTemplate.findById(id.value(), RentalApplicationDocument.class))
|
||||
.map(MongoRentalApplicationRepository::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RentalApplication save(RentalApplication application) {
|
||||
RentalApplicationDocument document = toDocument(application);
|
||||
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
|
||||
application.version(), document::withVersion));
|
||||
}
|
||||
|
||||
private static RentalApplicationDocument toDocument(RentalApplication application) {
|
||||
return new RentalApplicationDocument(application.id().value(), application.apartmentId().value(),
|
||||
application.landlordId().value(), application.state(), application.revision(), application.createdAt(),
|
||||
application.lastActivityAt(), application.capabilityHash(), application.encryptedApplicationKey(),
|
||||
application.cryptoVersion(), application.version() < 0 ? null : application.version());
|
||||
}
|
||||
|
||||
private static RentalApplication toDomain(RentalApplicationDocument document) {
|
||||
return RentalApplication.reconstitute(new RentalApplicationId(document.id()),
|
||||
new ApartmentId(document.apartmentId()), new LandlordId(document.landlordId()), document.state(),
|
||||
document.revision(), document.createdAt(), document.lastActivityAt(), document.capabilityHash(),
|
||||
document.encryptedApplicationKey(), document.cryptoVersion(),
|
||||
document.version() == null ? -1 : document.version());
|
||||
}
|
||||
}
|
||||
|
||||
@Document("rentalApplications")
|
||||
record RentalApplicationDocument(@Id String id, String apartmentId, String landlordId, RentalApplicationState state,
|
||||
int revision, Instant createdAt, Instant lastActivityAt, String capabilityHash, String encryptedApplicationKey,
|
||||
int cryptoVersion, @Version Long version) {
|
||||
RentalApplicationDocument withVersion(long updatedVersion) {
|
||||
return new RentalApplicationDocument(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
|
||||
capabilityHash, encryptedApplicationKey, cryptoVersion, updatedVersion);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
public final class ApplicationCreationRateLimited extends RuntimeException {
|
||||
public ApplicationCreationRateLimited() {
|
||||
super("Trop de candidatures ont été créées depuis ce réseau. Réessayez plus tard.");
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ApplicationCreationRateLimiter {
|
||||
private final int maximumAttempts;
|
||||
private final Duration window;
|
||||
private final Clock clock;
|
||||
private final ConcurrentHashMap<Key, Counter> attempts = new ConcurrentHashMap<>();
|
||||
|
||||
public ApplicationCreationRateLimiter(int maximumAttempts, Duration window, Clock clock) {
|
||||
if (maximumAttempts < 1 || window == null || window.isZero() || window.isNegative()) {
|
||||
throw new IllegalArgumentException("Application creation rate limit must be positive.");
|
||||
}
|
||||
this.maximumAttempts = maximumAttempts;
|
||||
this.window = window;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public boolean acquire(String linkToken, String remoteAddress) {
|
||||
Instant now = Instant.now(clock);
|
||||
attempts.entrySet().removeIf(entry -> !now.isBefore(entry.getValue().startedAt().plus(window)));
|
||||
Counter counter = attempts.compute(new Key(linkToken, remoteAddress),
|
||||
(key, current) -> current == null || !now.isBefore(current.startedAt().plus(window))
|
||||
? new Counter(now, 1)
|
||||
: current.increment());
|
||||
return counter.count() <= maximumAttempts;
|
||||
}
|
||||
|
||||
private record Key(String linkToken, String remoteAddress) {
|
||||
private Key {
|
||||
if (linkToken == null || linkToken.isBlank() || remoteAddress == null || remoteAddress.isBlank()) {
|
||||
throw new IllegalArgumentException("Rate-limit key is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Counter(Instant startedAt, int count) {
|
||||
Counter increment() {
|
||||
return new Counter(startedAt, count + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class ApplicationLinkService {
|
||||
private static final String CAPABILITY_SCHEME = "Application ";
|
||||
private final ApartmentRepository apartments;
|
||||
private final LandlordRepository landlords;
|
||||
private final ApplicationVaultRepository vaults;
|
||||
private final PublicApplicationLinkRepository links;
|
||||
private final RentalApplicationRepository applications;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
private final ApplicationCreationRateLimiter rateLimiter;
|
||||
|
||||
public ApplicationLinkService(ApartmentRepository apartments, LandlordRepository landlords,
|
||||
ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
|
||||
RentalApplicationRepository applications, TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock clock, ApplicationCreationRateLimiter rateLimiter) {
|
||||
this.apartments = apartments;
|
||||
this.landlords = landlords;
|
||||
this.vaults = vaults;
|
||||
this.links = links;
|
||||
this.applications = applications;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
public LinkStatus status(LandlordId landlordId, ApartmentId apartmentId) {
|
||||
apartment(landlordId, apartmentId);
|
||||
ApplicationVault vault = vaults.find(landlordId).orElse(null);
|
||||
return new LinkStatus(vault != null, vault == null ? 0 : vault.passkeys().size(),
|
||||
links.find(apartmentId).filter(PublicApplicationLink::active).orElse(null));
|
||||
}
|
||||
|
||||
public LinkStatus publish(LandlordId landlordId, ApartmentId apartmentId, VaultActivation activation) {
|
||||
return transactions.run(() -> {
|
||||
apartment(landlordId, apartmentId);
|
||||
ApplicationVault vault = vaults.find(landlordId).orElseGet(() -> activate(landlordId, activation));
|
||||
PublicApplicationLink active = links.find(apartmentId).filter(PublicApplicationLink::active).orElse(null);
|
||||
if (active != null) {
|
||||
return new LinkStatus(true, vault.passkeys().size(), active);
|
||||
}
|
||||
PublicApplicationLink published = PublicApplicationLink.publish(apartmentId, landlordId, identifiers.next(),
|
||||
Instant.now(clock));
|
||||
links.save(published);
|
||||
return new LinkStatus(true, vault.passkeys().size(), published);
|
||||
});
|
||||
}
|
||||
|
||||
public PublicLinkView preview(String token) {
|
||||
PublicApplicationLink link = activeLink(token);
|
||||
ApplicationVault vault = vaults.find(link.landlordId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Application link"));
|
||||
Landlord landlord = landlords.find(link.landlordId()).orElseThrow(() -> new ResourceNotFound("Landlord"));
|
||||
Apartment apartment = apartment(link.landlordId(), link.apartmentId());
|
||||
return view(link, vault, landlord, apartment);
|
||||
}
|
||||
|
||||
public RentalApplication createDraft(String token, String authorization, EncryptedApplicationKey encryptedKey,
|
||||
String remoteAddress) {
|
||||
activeLink(token);
|
||||
if (!rateLimiter.acquire(token, remoteAddress)) {
|
||||
throw new ApplicationCreationRateLimited();
|
||||
}
|
||||
String capabilityHash = capabilityHash(authorization);
|
||||
return transactions.run(() -> {
|
||||
PublicApplicationLink link = activeLink(token);
|
||||
vaults.find(link.landlordId()).orElseThrow(() -> new ResourceNotFound("Application link"));
|
||||
if (encryptedKey == null) {
|
||||
throw new IllegalArgumentException("Encrypted application key is required.");
|
||||
}
|
||||
RentalApplication draft = RentalApplication.draft(new RentalApplicationId(identifiers.next()),
|
||||
link.apartmentId(), link.landlordId(), Instant.now(clock), capabilityHash,
|
||||
encryptedKey.ciphertext(), encryptedKey.cryptoVersion());
|
||||
link.recordDraftCreation(Instant.now(clock));
|
||||
links.save(link);
|
||||
return applications.save(draft);
|
||||
});
|
||||
}
|
||||
|
||||
public CandidateApplicationView resume(RentalApplicationId id, String authorization) {
|
||||
RentalApplication application = applications.find(id).orElseThrow(() -> new ResourceNotFound("Application"));
|
||||
if (!application.authorizes(capabilityHash(authorization))) {
|
||||
throw new ResourceNotFound("Application");
|
||||
}
|
||||
Landlord landlord = landlords.find(application.landlordId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Landlord"));
|
||||
Apartment apartment = apartment(application.landlordId(), application.apartmentId());
|
||||
return new CandidateApplicationView(application, landlord.firstName() + " " + landlord.lastName(),
|
||||
landlord.email(), apartment.name());
|
||||
}
|
||||
|
||||
private ApplicationVault activate(LandlordId landlordId, VaultActivation activation) {
|
||||
if (activation == null) {
|
||||
throw new ApplicationVaultRequired();
|
||||
}
|
||||
ApplicationPasskey passkey = new ApplicationPasskey(activation.credentialId(), activation.label(),
|
||||
activation.prfSalt(), activation.hkdfSalt(), activation.nonce(), activation.encryptedPrivateKey(),
|
||||
activation.cryptoVersion(), Instant.now(clock), null);
|
||||
return vaults.save(ApplicationVault.activate(landlordId, activation.publicKey(), passkey));
|
||||
}
|
||||
|
||||
private PublicApplicationLink activeLink(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new ResourceNotFound("Application link");
|
||||
}
|
||||
return links.findActiveByToken(token).orElseThrow(() -> new ResourceNotFound("Application link"));
|
||||
}
|
||||
|
||||
private Apartment apartment(LandlordId landlordId, ApartmentId apartmentId) {
|
||||
return apartments.find(landlordId, apartmentId).orElseThrow(() -> new ResourceNotFound("Apartment"));
|
||||
}
|
||||
|
||||
private static PublicLinkView view(PublicApplicationLink link, ApplicationVault vault, Landlord landlord,
|
||||
Apartment apartment) {
|
||||
return new PublicLinkView(link.token(), link.createdAt(), vault.publicKey(),
|
||||
landlord.firstName() + " " + landlord.lastName(), landlord.email(), apartment.name());
|
||||
}
|
||||
|
||||
private static String capabilityHash(String authorization) {
|
||||
if (authorization == null || !authorization.startsWith(CAPABILITY_SCHEME)) {
|
||||
throw new ResourceNotFound("Application");
|
||||
}
|
||||
String capability = authorization.substring(CAPABILITY_SCHEME.length());
|
||||
try {
|
||||
byte[] decoded = Base64.getUrlDecoder().decode(capability);
|
||||
if (decoded.length != 32) {
|
||||
throw new ResourceNotFound("Application");
|
||||
}
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(MessageDigest.getInstance("SHA-256").digest(decoded));
|
||||
} catch (IllegalArgumentException | NoSuchAlgorithmException failure) {
|
||||
throw new ResourceNotFound("Application");
|
||||
}
|
||||
}
|
||||
|
||||
public record LinkStatus(boolean vaultActive, int passkeyCount, PublicApplicationLink activeLink) {
|
||||
}
|
||||
|
||||
public record VaultActivation(String publicKey, String credentialId, String label, String prfSalt, String hkdfSalt,
|
||||
String nonce, String encryptedPrivateKey, int cryptoVersion) {
|
||||
}
|
||||
|
||||
public record EncryptedApplicationKey(int cryptoVersion, String ciphertext) {
|
||||
}
|
||||
|
||||
public record PublicLinkView(String token, Instant createdAt, String landlordPublicKey, String landlordName,
|
||||
String landlordEmail, String apartmentName) {
|
||||
}
|
||||
|
||||
public record CandidateApplicationView(RentalApplication application, String landlordName, String landlordEmail,
|
||||
String apartmentName) {
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
public final class ApplicationVaultRequired extends RuntimeException {
|
||||
public ApplicationVaultRequired() {
|
||||
super("Activez le coffre de candidatures avant de publier ce lien.");
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
final class ApplicationCryptoValue {
|
||||
private ApplicationCryptoValue() {
|
||||
}
|
||||
|
||||
static String encoded(String value, String field, int minimumBytes, int maximumBytes) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required.");
|
||||
}
|
||||
int length;
|
||||
try {
|
||||
length = Base64.getUrlDecoder().decode(value).length;
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IllegalArgumentException(field + " must use base64url encoding.", failure);
|
||||
}
|
||||
if (length < minimumBytes || length > maximumBytes) {
|
||||
throw new IllegalArgumentException(field + " has an invalid length.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static int version(int version) {
|
||||
if (version != 1) {
|
||||
throw new IllegalArgumentException("Unsupported application cryptographic format.");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ApplicationPasskey(String credentialId, String label, String prfSalt, String hkdfSalt, String nonce,
|
||||
String encryptedPrivateKey, int cryptoVersion, Instant createdAt, Instant lastUsedAt) {
|
||||
public ApplicationPasskey {
|
||||
credentialId = ApplicationCryptoValue.encoded(credentialId, "Credential ID", 1, 1024);
|
||||
if (label == null || label.isBlank() || label.length() > 100) {
|
||||
throw new IllegalArgumentException("Passkey label is required and limited to 100 characters.");
|
||||
}
|
||||
label = label.trim();
|
||||
prfSalt = ApplicationCryptoValue.encoded(prfSalt, "PRF salt", 32, 32);
|
||||
hkdfSalt = ApplicationCryptoValue.encoded(hkdfSalt, "HKDF salt", 32, 32);
|
||||
nonce = ApplicationCryptoValue.encoded(nonce, "Private-key envelope nonce", 12, 12);
|
||||
encryptedPrivateKey = ApplicationCryptoValue.encoded(encryptedPrivateKey, "Encrypted private key", 512, 8192);
|
||||
cryptoVersion = ApplicationCryptoValue.version(cryptoVersion);
|
||||
if (createdAt == null) {
|
||||
throw new IllegalArgumentException("Passkey creation time is required.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ApplicationVault {
|
||||
private final LandlordId landlordId;
|
||||
private final String publicKey;
|
||||
private final List<ApplicationPasskey> passkeys;
|
||||
private final long version;
|
||||
|
||||
private ApplicationVault(LandlordId landlordId, String publicKey, List<ApplicationPasskey> passkeys, long version) {
|
||||
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
|
||||
this.publicKey = ApplicationCryptoValue.encoded(publicKey, "Landlord public key", 384, 1024);
|
||||
this.passkeys = List.copyOf(Objects.requireNonNull(passkeys, "Application passkeys are required."));
|
||||
if (this.passkeys.isEmpty()) {
|
||||
throw new IllegalArgumentException("An application vault requires a passkey.");
|
||||
}
|
||||
if (this.passkeys.stream().map(ApplicationPasskey::credentialId).distinct().count() != this.passkeys.size()) {
|
||||
throw new IllegalArgumentException("Application passkey credential IDs must be unique.");
|
||||
}
|
||||
if (version < -1) {
|
||||
throw new IllegalArgumentException("Application vault version cannot be less than -1.");
|
||||
}
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public static ApplicationVault activate(LandlordId landlordId, String publicKey, ApplicationPasskey passkey) {
|
||||
return new ApplicationVault(landlordId, publicKey, List.of(passkey), -1);
|
||||
}
|
||||
|
||||
public static ApplicationVault reconstitute(LandlordId landlordId, String publicKey,
|
||||
List<ApplicationPasskey> passkeys, long version) {
|
||||
return new ApplicationVault(landlordId, publicKey, passkeys, version);
|
||||
}
|
||||
|
||||
public LandlordId landlordId() {
|
||||
return landlordId;
|
||||
}
|
||||
|
||||
public String publicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
public List<ApplicationPasskey> passkeys() {
|
||||
return passkeys;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ApplicationVaultRepository {
|
||||
Optional<ApplicationVault> find(LandlordId landlordId);
|
||||
|
||||
ApplicationVault save(ApplicationVault vault);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PublicApplicationLink {
|
||||
private final ApartmentId apartmentId;
|
||||
private final LandlordId landlordId;
|
||||
private final String token;
|
||||
private final Instant createdAt;
|
||||
private final Instant closedAt;
|
||||
private final long version;
|
||||
private long creationCount;
|
||||
private Instant lastCreatedAt;
|
||||
|
||||
private PublicApplicationLink(ApartmentId apartmentId, LandlordId landlordId, String token, Instant createdAt,
|
||||
Instant closedAt, long creationCount, Instant lastCreatedAt, long version) {
|
||||
this.apartmentId = Objects.requireNonNull(apartmentId, "Apartment ID is required.");
|
||||
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new IllegalArgumentException("Public application token is required.");
|
||||
}
|
||||
this.token = token;
|
||||
this.createdAt = Objects.requireNonNull(createdAt, "Public application link creation time is required.");
|
||||
this.closedAt = closedAt;
|
||||
if (creationCount < 0 || version < -1) {
|
||||
throw new IllegalArgumentException("Public application link counters are invalid.");
|
||||
}
|
||||
this.creationCount = creationCount;
|
||||
this.lastCreatedAt = lastCreatedAt;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public static PublicApplicationLink publish(ApartmentId apartmentId, LandlordId landlordId, String token,
|
||||
Instant createdAt) {
|
||||
return new PublicApplicationLink(apartmentId, landlordId, token, createdAt, null, 0, null, -1);
|
||||
}
|
||||
|
||||
public static PublicApplicationLink reconstitute(ApartmentId apartmentId, LandlordId landlordId, String token,
|
||||
Instant createdAt, Instant closedAt, long creationCount, Instant lastCreatedAt, long version) {
|
||||
return new PublicApplicationLink(apartmentId, landlordId, token, createdAt, closedAt, creationCount,
|
||||
lastCreatedAt, version);
|
||||
}
|
||||
|
||||
public void recordDraftCreation(Instant createdAt) {
|
||||
if (!active()) {
|
||||
throw new IllegalStateException("Public application link is closed.");
|
||||
}
|
||||
creationCount++;
|
||||
lastCreatedAt = Objects.requireNonNull(createdAt);
|
||||
}
|
||||
|
||||
public boolean active() {
|
||||
return closedAt == null;
|
||||
}
|
||||
|
||||
public ApartmentId apartmentId() {
|
||||
return apartmentId;
|
||||
}
|
||||
|
||||
public LandlordId landlordId() {
|
||||
return landlordId;
|
||||
}
|
||||
|
||||
public String token() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public Instant createdAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant closedAt() {
|
||||
return closedAt;
|
||||
}
|
||||
|
||||
public long creationCount() {
|
||||
return creationCount;
|
||||
}
|
||||
|
||||
public Instant lastCreatedAt() {
|
||||
return lastCreatedAt;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PublicApplicationLinkRepository {
|
||||
Optional<PublicApplicationLink> find(ApartmentId apartmentId);
|
||||
|
||||
Optional<PublicApplicationLink> findActiveByToken(String token);
|
||||
|
||||
PublicApplicationLink save(PublicApplicationLink link);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class RentalApplication {
|
||||
private final RentalApplicationId id;
|
||||
private final ApartmentId apartmentId;
|
||||
private final LandlordId landlordId;
|
||||
private final RentalApplicationState state;
|
||||
private final int revision;
|
||||
private final Instant createdAt;
|
||||
private final Instant lastActivityAt;
|
||||
private final String capabilityHash;
|
||||
private final String encryptedApplicationKey;
|
||||
private final int cryptoVersion;
|
||||
private final long version;
|
||||
|
||||
private RentalApplication(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
|
||||
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt,
|
||||
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
|
||||
this.id = Objects.requireNonNull(id, "Rental application ID is required.");
|
||||
this.apartmentId = Objects.requireNonNull(apartmentId, "Apartment ID is required.");
|
||||
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
|
||||
this.state = Objects.requireNonNull(state, "Rental application state is required.");
|
||||
if (revision < 0 || version < -1) {
|
||||
throw new IllegalArgumentException("Rental application revisions are invalid.");
|
||||
}
|
||||
this.revision = revision;
|
||||
this.createdAt = Objects.requireNonNull(createdAt, "Rental application creation time is required.");
|
||||
this.lastActivityAt = Objects.requireNonNull(lastActivityAt, "Rental application activity time is required.");
|
||||
this.capabilityHash = ApplicationCryptoValue.encoded(capabilityHash, "Capability hash", 32, 32);
|
||||
this.encryptedApplicationKey = ApplicationCryptoValue.encoded(encryptedApplicationKey,
|
||||
"Encrypted application key", 384, 384);
|
||||
this.cryptoVersion = ApplicationCryptoValue.version(cryptoVersion);
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public static RentalApplication draft(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
|
||||
Instant createdAt, String capabilityHash, String encryptedApplicationKey, int cryptoVersion) {
|
||||
return new RentalApplication(id, apartmentId, landlordId, RentalApplicationState.DRAFT, 0, createdAt, createdAt,
|
||||
capabilityHash, encryptedApplicationKey, cryptoVersion, -1);
|
||||
}
|
||||
|
||||
public static RentalApplication reconstitute(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
|
||||
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt,
|
||||
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
|
||||
return new RentalApplication(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
|
||||
capabilityHash, encryptedApplicationKey, cryptoVersion, version);
|
||||
}
|
||||
|
||||
public boolean authorizes(String presentedHash) {
|
||||
return presentedHash != null && MessageDigest.isEqual(capabilityHash.getBytes(StandardCharsets.US_ASCII),
|
||||
presentedHash.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
public RentalApplicationId id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public ApartmentId apartmentId() {
|
||||
return apartmentId;
|
||||
}
|
||||
|
||||
public LandlordId landlordId() {
|
||||
return landlordId;
|
||||
}
|
||||
|
||||
public RentalApplicationState state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public int revision() {
|
||||
return revision;
|
||||
}
|
||||
|
||||
public Instant createdAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
}
|
||||
|
||||
public String capabilityHash() {
|
||||
return capabilityHash;
|
||||
}
|
||||
|
||||
public String encryptedApplicationKey() {
|
||||
return encryptedApplicationKey;
|
||||
}
|
||||
|
||||
public int cryptoVersion() {
|
||||
return cryptoVersion;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
public record RentalApplicationId(String value) {
|
||||
public RentalApplicationId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Rental application ID is required.");
|
||||
}
|
||||
value = value.trim();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RentalApplicationRepository {
|
||||
Optional<RentalApplication> find(RentalApplicationId id);
|
||||
|
||||
RentalApplication save(RentalApplication application);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.application;
|
||||
|
||||
public enum RentalApplicationState {
|
||||
DRAFT, SUBMITTED, SELECTED, REJECTED, WITHDRAWN, EXPIRED
|
||||
}
|
||||
@@ -15,6 +15,8 @@ lmnp.condition-photos.timeout=${LMNP_CONDITION_PHOTO_TIMEOUT:20s}
|
||||
lmnp.insee-irl.base-uri=${INSEE_IRL_BASE_URI:https://bdm.insee.fr/series/sdmx/data/SERIES_BDM/}
|
||||
lmnp.insee-irl.request-timeout=${INSEE_IRL_REQUEST_TIMEOUT:5s}
|
||||
lmnp.e2e.clock.fixed-instant=${E2E_FIXED_INSTANT:2027-09-02T12:00:00Z}
|
||||
lmnp.applications.creation-rate-limit.maximum-attempts=${APPLICATION_CREATION_RATE_LIMIT:10}
|
||||
lmnp.applications.creation-rate-limit.window=${APPLICATION_CREATION_RATE_WINDOW:1h}
|
||||
# OAuth2 Client registration
|
||||
spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client}
|
||||
spring.security.oauth2.client.registration.oidc.client-secret=${OIDC_CLIENT_SECRET}
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.application;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimited;
|
||||
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationLinkService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest(controllers = {LandlordApplicationLinkController.class, PublicApplicationController.class,
|
||||
CandidateApplicationController.class})
|
||||
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
|
||||
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class,
|
||||
ApplicationSecurityHeadersFilter.class})
|
||||
class ApplicationWebTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
|
||||
private static final Instant NOW = Instant.parse("2026-09-04T10:00:00Z");
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
@MockitoBean
|
||||
ApplicationLinkService links;
|
||||
@MockitoBean
|
||||
LandlordAccountService accounts;
|
||||
|
||||
@BeforeEach
|
||||
void identifiesTheAuthenticatedLandlord() {
|
||||
when(accounts.identify(any()))
|
||||
.thenReturn(Landlord.register(LANDLORD_ID, new ExternalIdentity("https://issuer.example", "subject-1"),
|
||||
"Camille", "Martin", "camille@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicPreviewIsAnonymousEffectlessAndNeverCacheable() throws Exception {
|
||||
when(links.preview("public-token")).thenReturn(new ApplicationLinkService.PublicLinkView("public-token", NOW,
|
||||
encoded(550, 1), "Camille Martin", "camille@example.com", "Studio"));
|
||||
|
||||
mockMvc.perform(get("/api/public/application-links/public-token")).andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
|
||||
.andExpect(header().string("Referrer-Policy", "no-referrer"))
|
||||
.andExpect(jsonPath("$.apartmentName").value("Studio"));
|
||||
|
||||
verify(links).preview("public-token");
|
||||
verifyNoMoreInteractions(links);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousClientCreatesADraftWithoutCsrfAndPassesTheCapabilityOnlyAsAuthorization() throws Exception {
|
||||
RentalApplication draft = RentalApplication.draft(new RentalApplicationId("application-1"), APARTMENT_ID,
|
||||
LANDLORD_ID, NOW, encoded(32, 2), encoded(384, 3), 1);
|
||||
when(links.createDraft(eq("public-token"), eq("Application candidate-capability"), any(), any()))
|
||||
.thenReturn(draft);
|
||||
|
||||
mockMvc.perform(post("/api/public/application-links/public-token/applications")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Application candidate-capability")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"encryptedApplicationKey":{"cryptoVersion":1,"ciphertext":"encrypted-key"}}
|
||||
""")).andExpect(status().isCreated())
|
||||
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
|
||||
.andExpect(jsonPath("$.id").value("application-1")).andExpect(jsonPath("$.state").value("DRAFT"));
|
||||
|
||||
verify(links).createDraft(eq("public-token"), eq("Application candidate-capability"), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateCapabilityRouteIsAnonymousAndReturnsNoDistinguishingFailure() throws Exception {
|
||||
when(links.resume(new RentalApplicationId("unknown"), "Application invalid"))
|
||||
.thenThrow(new ResourceNotFound("Application"));
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/candidate/applications/unknown").header(HttpHeaders.AUTHORIZATION, "Application invalid"))
|
||||
.andExpect(status().isNotFound()).andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
|
||||
.andExpect(jsonPath("$.code").value("resource_not_found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void landlordLinkRouteRequiresOidcAndUsesTheAuthenticatedPortfolioOwner() throws Exception {
|
||||
when(links.status(LANDLORD_ID, APARTMENT_ID)).thenReturn(new ApplicationLinkService.LinkStatus(true, 1,
|
||||
PublicApplicationLink.publish(APARTMENT_ID, LANDLORD_ID, "public-token", NOW)));
|
||||
|
||||
mockMvc.perform(get("/api/application-links/apartments/apartment-1")).andExpect(status().isUnauthorized());
|
||||
mockMvc.perform(get("/api/application-links/apartments/apartment-1")
|
||||
.with(oidcLogin().idToken(token -> token.issuer("https://issuer.example").claim("sub", "subject-1"))))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.activeLink.token").value("public-token"));
|
||||
|
||||
verify(links).status(LANDLORD_ID, APARTMENT_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationRateLimitUsesTheStableApiErrorContract() throws Exception {
|
||||
when(links.createDraft(any(), any(), any(), any())).thenThrow(new ApplicationCreationRateLimited());
|
||||
|
||||
mockMvc.perform(post("/api/public/application-links/public-token/applications")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Application candidate-capability")
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"encryptedApplicationKey":{"cryptoVersion":1,"ciphertext":"encrypted-key"}}
|
||||
""")).andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.code").value("application_creation_rate_limited"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void landlordPublicationStillRequiresCsrf() throws Exception {
|
||||
mockMvc.perform(post("/api/application-links/apartments/apartment-1")
|
||||
.with(oidcLogin().idToken(token -> token.issuer("https://issuer.example"))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private static String encoded(int length, int value) {
|
||||
byte[] bytes = new byte[length];
|
||||
Arrays.fill(bytes, (byte) value);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ApplicationCreationRateLimiterTest {
|
||||
@Test
|
||||
void forgetsTheAddressWhenTheWindowExpires() {
|
||||
Instant now = Instant.parse("2026-09-04T10:00:00Z");
|
||||
Clock clock = mock(Clock.class);
|
||||
when(clock.instant()).thenReturn(now, now, now.plus(Duration.ofHours(1)));
|
||||
ApplicationCreationRateLimiter limiter = new ApplicationCreationRateLimiter(1, Duration.ofHours(1), clock);
|
||||
|
||||
assertTrue(limiter.acquire("link", "203.0.113.1"));
|
||||
assertFalse(limiter.acquire("link", "203.0.113.1"));
|
||||
|
||||
assertTrue(limiter.acquire("link", "203.0.113.1"));
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLink;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class ApplicationLinkServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
|
||||
private static final Instant NOW = Instant.parse("2026-09-04T10:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
private static final String CAPABILITY = encoded(32, 7);
|
||||
|
||||
@Test
|
||||
void firstPublicationAtomicallyActivatesTheVaultAndCreatesOneLink() {
|
||||
Fixture fixture = fixture("public-token");
|
||||
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.empty());
|
||||
when(fixture.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
|
||||
|
||||
ApplicationLinkService.LinkStatus published = fixture.service.publish(LANDLORD_ID, APARTMENT_ID, activation());
|
||||
|
||||
assertEquals("public-token", published.activeLink().token());
|
||||
assertEquals(true, published.vaultActive());
|
||||
verify(fixture.vaults).save(any(ApplicationVault.class));
|
||||
verify(fixture.links).save(any(PublicApplicationLink.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicationIsLimitedToTheAuthenticatedLandlordsApartment() {
|
||||
Fixture fixture = fixture("unused");
|
||||
|
||||
assertThrows(ResourceNotFound.class,
|
||||
() -> fixture.service.publish(new LandlordId("landlord-2"), APARTMENT_ID, activation()));
|
||||
verifyNoInteractions(fixture.vaults, fixture.links);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicationRequiresActivationOnlyWhenTheLandlordHasNoVault() {
|
||||
Fixture missing = fixture("unused");
|
||||
when(missing.vaults.find(LANDLORD_ID)).thenReturn(Optional.empty());
|
||||
when(missing.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
|
||||
assertThrows(ApplicationVaultRequired.class, () -> missing.service.publish(LANDLORD_ID, APARTMENT_ID, null));
|
||||
verify(missing.links, never()).save(any());
|
||||
|
||||
Fixture existing = fixture("public-token");
|
||||
when(existing.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
|
||||
when(existing.links.find(APARTMENT_ID)).thenReturn(Optional.empty());
|
||||
assertEquals("public-token", existing.service.publish(LANDLORD_ID, APARTMENT_ID, null).activeLink().token());
|
||||
verify(existing.vaults, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicPreviewIsReadOnlyAndCreationStoresOnlyACapabilityHash() {
|
||||
Fixture fixture = fixture("application-1");
|
||||
PublicApplicationLink link = link();
|
||||
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link));
|
||||
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
|
||||
|
||||
ApplicationLinkService.PublicLinkView preview = fixture.service.preview("public-token");
|
||||
assertEquals("Studio", preview.apartmentName());
|
||||
verify(fixture.applications, never()).save(any());
|
||||
verify(fixture.links, never()).save(any());
|
||||
|
||||
RentalApplication created = fixture.service.createDraft("public-token", "Application " + CAPABILITY,
|
||||
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
|
||||
|
||||
ArgumentCaptor<RentalApplication> saved = ArgumentCaptor.forClass(RentalApplication.class);
|
||||
verify(fixture.applications).save(saved.capture());
|
||||
assertEquals(new RentalApplicationId("application-1"), created.id());
|
||||
assertNotEquals(CAPABILITY, saved.getValue().capabilityHash());
|
||||
assertEquals(created.id(),
|
||||
fixture.service.resume(created.id(), "Application " + CAPABILITY).application().id());
|
||||
assertThrows(ResourceNotFound.class,
|
||||
() -> fixture.service.resume(created.id(), "Application " + encoded(32, 8)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyDraftIsNotReadWhileTheLandlordConsultsTheLink() {
|
||||
Fixture fixture = fixture("unused");
|
||||
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
|
||||
when(fixture.links.find(APARTMENT_ID)).thenReturn(Optional.of(link()));
|
||||
|
||||
fixture.service.status(LANDLORD_ID, APARTMENT_ID);
|
||||
|
||||
verifyNoInteractions(fixture.applications);
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationIsLimitedByPublicLinkAndRemoteAddress() {
|
||||
Fixture fixture = fixture("application-1", 1);
|
||||
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link()));
|
||||
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
|
||||
|
||||
fixture.service.createDraft("public-token", "Application " + CAPABILITY,
|
||||
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
|
||||
|
||||
assertThrows(ApplicationCreationRateLimited.class,
|
||||
() -> fixture.service.createDraft("public-token", "Application " + CAPABILITY,
|
||||
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownLinksDoNotConsumeRateLimitCapacity() {
|
||||
Fixture fixture = fixture("application-1", 1);
|
||||
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(ResourceNotFound.class,
|
||||
() -> fixture.service.createDraft("public-token", "Application " + CAPABILITY,
|
||||
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4"));
|
||||
|
||||
when(fixture.links.findActiveByToken("public-token")).thenReturn(Optional.of(link()));
|
||||
when(fixture.vaults.find(LANDLORD_ID)).thenReturn(Optional.of(vault()));
|
||||
fixture.service.createDraft("public-token", "Application " + CAPABILITY,
|
||||
new ApplicationLinkService.EncryptedApplicationKey(1, encoded(384, 11)), "203.0.113.4");
|
||||
}
|
||||
|
||||
private static Fixture fixture(String identifier) {
|
||||
return fixture(identifier, 5);
|
||||
}
|
||||
|
||||
private static Fixture fixture(String identifier, int limit) {
|
||||
ApartmentRepository apartments = mock(ApartmentRepository.class);
|
||||
LandlordRepository landlords = mock(LandlordRepository.class);
|
||||
ApplicationVaultRepository vaults = mock(ApplicationVaultRepository.class);
|
||||
PublicApplicationLinkRepository links = mock(PublicApplicationLinkRepository.class);
|
||||
RentalApplicationRepository applications = mock(RentalApplicationRepository.class);
|
||||
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment()));
|
||||
when(landlords.find(LANDLORD_ID)).thenReturn(Optional.of(landlord()));
|
||||
when(vaults.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(links.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
AtomicReference<RentalApplication> storedApplication = new AtomicReference<>();
|
||||
when(applications.save(any())).thenAnswer(invocation -> {
|
||||
RentalApplication saved = invocation.getArgument(0);
|
||||
storedApplication.set(saved);
|
||||
return saved;
|
||||
});
|
||||
when(applications.find(any())).thenAnswer(invocation -> {
|
||||
RentalApplication stored = storedApplication.get();
|
||||
RentalApplicationId requested = invocation.getArgument(0);
|
||||
return stored != null && stored.id().equals(requested) ? Optional.of(stored) : Optional.empty();
|
||||
});
|
||||
IdentifierGenerator identifiers = queued(identifier);
|
||||
TransactionRunner transactions = new TransactionRunner() {
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
ApplicationCreationRateLimiter limiter = new ApplicationCreationRateLimiter(limit, Duration.ofHours(1), CLOCK);
|
||||
ApplicationLinkService service = new ApplicationLinkService(apartments, landlords, vaults, links, applications,
|
||||
transactions, identifiers, CLOCK, limiter);
|
||||
return new Fixture(vaults, links, applications, service);
|
||||
}
|
||||
|
||||
private static IdentifierGenerator queued(String... values) {
|
||||
ArrayDeque<String> queue = new ArrayDeque<>(List.of(values));
|
||||
return queue::remove;
|
||||
}
|
||||
|
||||
private static ApplicationLinkService.VaultActivation activation() {
|
||||
return new ApplicationLinkService.VaultActivation(encoded(550, 1), encoded(32, 6), "Passkey principale",
|
||||
encoded(32, 2), encoded(32, 3), encoded(12, 4), encoded(2500, 5), 1);
|
||||
}
|
||||
|
||||
private static ApplicationVault vault() {
|
||||
ApplicationPasskey passkey = new ApplicationPasskey(encoded(32, 6), "Passkey principale", encoded(32, 2),
|
||||
encoded(32, 3), encoded(12, 4), encoded(2500, 5), 1, NOW, null);
|
||||
return ApplicationVault.activate(LANDLORD_ID, encoded(550, 1), passkey);
|
||||
}
|
||||
|
||||
private static PublicApplicationLink link() {
|
||||
return PublicApplicationLink.publish(APARTMENT_ID, LANDLORD_ID, "public-token", NOW);
|
||||
}
|
||||
|
||||
private static Apartment apartment() {
|
||||
return new Apartment(APARTMENT_ID, LANDLORD_ID, "Studio", "2 rue B", new BigDecimal("120000"),
|
||||
LocalDate.of(2024, 6, 15), LocalDate.of(2026, 8, 11), "", ApartmentLegalProfile.empty(), List.of());
|
||||
}
|
||||
|
||||
private static Landlord landlord() {
|
||||
Landlord landlord = Landlord.register(LANDLORD_ID, new ExternalIdentity("issuer", "subject"), "Camille",
|
||||
"Martin", "camille@example.com");
|
||||
landlord.updateProfile("Camille", "Martin", "camille@example.com", "06 12 34 56 78", "1 rue A");
|
||||
return landlord;
|
||||
}
|
||||
|
||||
private static String encoded(int length, int value) {
|
||||
byte[] bytes = new byte[length];
|
||||
Arrays.fill(bytes, (byte) value);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private record Fixture(ApplicationVaultRepository vaults, PublicApplicationLinkRepository links,
|
||||
RentalApplicationRepository applications, ApplicationLinkService service) {
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -12,13 +12,21 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
}
|
||||
|
||||
location ^~ /candidatures/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
@if (
|
||||
@if (publicExperience()) {
|
||||
<div class="public-shell">
|
||||
<header class="public-header">
|
||||
<app-brand-mark class="brand-mark" />
|
||||
<div>
|
||||
<strong>LMNP Organizer</strong>
|
||||
<span>Espace candidat sécurisé</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="public-main">
|
||||
@if (routeLoading()) {
|
||||
<mat-progress-bar
|
||||
class="route-progress"
|
||||
mode="indeterminate"
|
||||
aria-label="Chargement de la page"
|
||||
/>
|
||||
}
|
||||
@if (routeLoadError()) {
|
||||
<div class="route-load-error" role="alert">
|
||||
<span>{{ routeLoadError() }}</span>
|
||||
<button mat-button type="button" (click)="retryRouteLoad()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
} @else if (
|
||||
session.status() === 'checking' ||
|
||||
session.status() === 'signing-out' ||
|
||||
(session.isLoggedIn() && profileCheckStatus() === 'checking')
|
||||
|
||||
@@ -2,6 +2,20 @@ import { Routes } from '@angular/router';
|
||||
import { administratorGuard } from './services/administrator.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'candidatures/postuler/:token',
|
||||
loadComponent: () =>
|
||||
import('./components/public-application-page/public-application-page').then(
|
||||
({ PublicApplicationPage }) => PublicApplicationPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'candidatures/reprendre',
|
||||
loadComponent: () =>
|
||||
import('./components/candidate-application-page/candidate-application-page').then(
|
||||
({ CandidateApplicationPage }) => CandidateApplicationPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -51,6 +51,39 @@
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.public-shell {
|
||||
min-height: 100vh;
|
||||
padding: tokens.space(4);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.public-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: tokens.space(3);
|
||||
width: min(100%, 48rem);
|
||||
margin-inline: auto;
|
||||
padding-block: tokens.space(2) tokens.space(6);
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.public-main {
|
||||
container-type: inline-size;
|
||||
width: min(100%, 48rem);
|
||||
margin-inline: auto;
|
||||
padding-block-end: tokens.space(8);
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('App', () => {
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
status.set('checking');
|
||||
clearListeners.clear();
|
||||
session.isLoggedIn.set(false);
|
||||
@@ -74,6 +75,16 @@ describe('App', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders candidate routes without starting the OIDC session bootstrap', () => {
|
||||
window.history.replaceState({}, '', '/candidatures/reprendre#application=one');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(session.refresh).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('.public-shell')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-sign-in-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a neutral session loader before authentication resolves', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
+12
-1
@@ -80,12 +80,19 @@ export class App implements OnInit {
|
||||
readonly profileCheckStatus = signal<'idle' | 'checking' | 'checked' | 'error'>('idle');
|
||||
readonly routeLoading = signal(false);
|
||||
readonly routeLoadError = signal('');
|
||||
readonly publicExperience = signal(isApplicationPath(globalThis.location.pathname));
|
||||
|
||||
constructor() {
|
||||
this.session.onClear(() => this.profileCompletionDialog?.close());
|
||||
|
||||
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
const wasPublic = this.publicExperience();
|
||||
const isPublic = isApplicationPath(event.url);
|
||||
this.publicExperience.set(isPublic);
|
||||
if (wasPublic && !isPublic && this.session.status() === 'checking') {
|
||||
this.session.refresh();
|
||||
}
|
||||
this.routeLoadError.set('');
|
||||
} else if (event instanceof RouteConfigLoadStart) {
|
||||
this.loadingRoutes.add(event.route);
|
||||
@@ -124,7 +131,7 @@ export class App implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.session.refresh();
|
||||
if (!this.publicExperience()) this.session.refresh();
|
||||
}
|
||||
|
||||
login() {
|
||||
@@ -167,3 +174,7 @@ export class App implements OnInit {
|
||||
.catch(() => this.profileCheckStatus.set('error'));
|
||||
}
|
||||
}
|
||||
|
||||
function isApplicationPath(url: string): boolean {
|
||||
return url === '/candidatures' || url.startsWith('/candidatures/');
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
Créer le plan d’amortissement
|
||||
</button>
|
||||
<app-apartment-document-manager [apartmentId]="apartmentId" />
|
||||
<app-application-link-manager [apartmentId]="apartmentId" />
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -143,7 +143,14 @@ describe('ApartmentsPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
afterEach(() => {
|
||||
http.match((request) =>
|
||||
request.url.startsWith('/api/application-links/apartments/'),
|
||||
).forEach((request) =>
|
||||
request.flush({ vaultActive: false, passkeyCount: 0, activeLink: null }),
|
||||
);
|
||||
http.verify();
|
||||
});
|
||||
|
||||
it('shows apartment-shaped placeholders instead of a false empty state on first load', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentsPage);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ApartmentList } from '../apartment-list/apartment-list';
|
||||
import { ApartmentDocumentManager } from '../apartment-document-manager/apartment-document-manager';
|
||||
import { ConfirmationDialog } from '../confirmation-dialog/confirmation-dialog';
|
||||
import { ApartmentAcquisitionCorrection } from '../apartment-acquisition-correction/apartment-acquisition-correction';
|
||||
import { ApplicationLinkManager } from '../application-link-manager/application-link-manager';
|
||||
|
||||
@Component({
|
||||
selector: 'app-apartments-page',
|
||||
@@ -24,6 +25,7 @@ import { ApartmentAcquisitionCorrection } from '../apartment-acquisition-correct
|
||||
ApartmentDocumentManager,
|
||||
ApartmentForm,
|
||||
ApartmentList,
|
||||
ApplicationLinkManager,
|
||||
],
|
||||
templateUrl: './apartments-page.html',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<section class="application-link-manager" aria-labelledby="application-link-title">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Candidatures locatives</p>
|
||||
<h2 id="application-link-title">Lien public de candidature</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="local-state" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="28" />
|
||||
<span>Chargement du lien…</span>
|
||||
</div>
|
||||
} @else if (status()?.activeLink; as link) {
|
||||
<p>Partagez ce lien avec les candidats. Chaque ouverture crée un dossier privé distinct.</p>
|
||||
<a class="application-url" [href]="link.path" target="_blank" rel="noopener noreferrer">
|
||||
{{ link.path }}
|
||||
</a>
|
||||
@if (status()!.passkeyCount < 2) {
|
||||
<p class="passkey-warning" role="status">
|
||||
Ajoutez bientôt une seconde Passkey depuis les réglages pour éviter une perte
|
||||
d’accès.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p>
|
||||
Publiez un lien pour recevoir des dossiers chiffrés. La première publication active
|
||||
votre coffre avec une Passkey compatible.
|
||||
</p>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="primary-action"
|
||||
[disabled]="publishing()"
|
||||
(click)="publish()"
|
||||
>
|
||||
@if (publishing()) {
|
||||
<mat-spinner diameter="18" />
|
||||
}
|
||||
{{ publishing() ? 'Publication…' : 'Publier le lien' }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="alert-error" role="alert">
|
||||
<p>{{ error() }}</p>
|
||||
@if (!status()) {
|
||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (compatibilityHelp()) {
|
||||
<p class="compatibility-help" role="alert">{{ compatibilityHelp() }}</p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
@use '../../../tokens' as tokens;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.application-link-manager {
|
||||
@include tokens.card;
|
||||
display: grid;
|
||||
gap: tokens.space(4);
|
||||
margin-block-start: tokens.space(6);
|
||||
padding: tokens.space(5);
|
||||
container-type: inline-size;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.local-state {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: tokens.space(3);
|
||||
min-height: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.application-url {
|
||||
min-width: 0;
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.passkey-warning,
|
||||
.compatibility-help {
|
||||
padding: tokens.space(3);
|
||||
border-radius: tokens.radius(md);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
margin: 0;
|
||||
|
||||
p {
|
||||
margin-block-end: tokens.space(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
ApplicationPasskeyError,
|
||||
ApplicationPasskeyService,
|
||||
} from '../../services/application-passkey.service';
|
||||
import { RentalApplicationService } from '../../services/rental-application.service';
|
||||
import { ApplicationLinkManager } from './application-link-manager';
|
||||
|
||||
describe('ApplicationLinkManager', () => {
|
||||
const inactive = { vaultActive: false, passkeyCount: 0, activeLink: null };
|
||||
const active = {
|
||||
vaultActive: true,
|
||||
passkeyCount: 1,
|
||||
activeLink: {
|
||||
token: 'public-token',
|
||||
path: '/candidatures/postuler/public-token',
|
||||
createdAt: '2026-09-04T10:00:00Z',
|
||||
},
|
||||
};
|
||||
const applications = {
|
||||
getLinkStatus: vi.fn(() => of(inactive)),
|
||||
publishLink: vi.fn(() => of(active)),
|
||||
};
|
||||
const passkeys = { enroll: vi.fn() };
|
||||
const dialog = { open: vi.fn() };
|
||||
let fixture: ComponentFixture<ApplicationLinkManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
applications.getLinkStatus.mockReturnValue(of(inactive));
|
||||
applications.publishLink.mockReturnValue(of(active));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(false) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: RentalApplicationService, useValue: applications },
|
||||
{ provide: ApplicationPasskeyService, useValue: passkeys },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(ApplicationLinkManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apt-1');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('does not create a vault or link when the explanation is cancelled', () => {
|
||||
button('Publier le lien').click();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(passkeys.enroll).not.toHaveBeenCalled();
|
||||
expect(applications.publishLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed with compatibility help when PRF is unavailable', async () => {
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
passkeys.enroll.mockRejectedValue(new ApplicationPasskeyError('prf_unsupported'));
|
||||
|
||||
button('Publier le lien').click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(applications.publishLink).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('fonction PRF requise');
|
||||
});
|
||||
|
||||
it('publishes directly when the landlord already has a vault', () => {
|
||||
applications.getLinkStatus.mockReturnValue(
|
||||
of({ ...inactive, vaultActive: true, passkeyCount: 1 }),
|
||||
);
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(ApplicationLinkManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apt-1');
|
||||
fixture.detectChanges();
|
||||
|
||||
button('Publier le lien').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
expect(passkeys.enroll).not.toHaveBeenCalled();
|
||||
expect(applications.publishLink).toHaveBeenCalledWith('apt-1', undefined);
|
||||
expect(fixture.nativeElement.textContent).toContain('/candidatures/postuler/public-token');
|
||||
});
|
||||
|
||||
function button(label: string): HTMLButtonElement {
|
||||
return [...fixture.nativeElement.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(label),
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
ApplicationPasskeyError,
|
||||
ApplicationPasskeyService,
|
||||
} from '../../services/application-passkey.service';
|
||||
import {
|
||||
ApplicationLinkStatus,
|
||||
RentalApplicationService,
|
||||
} from '../../services/rental-application.service';
|
||||
import { ConfirmationDialog } from '../confirmation-dialog/confirmation-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-application-link-manager',
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule],
|
||||
templateUrl: './application-link-manager.html',
|
||||
styleUrl: './application-link-manager.scss',
|
||||
})
|
||||
export class ApplicationLinkManager implements OnInit {
|
||||
readonly apartmentId = input.required<string>();
|
||||
readonly status = signal<ApplicationLinkStatus | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly compatibilityHelp = signal('');
|
||||
private readonly applications = inject(RentalApplicationService);
|
||||
private readonly passkeys = inject(ApplicationPasskeyService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.applications
|
||||
.getLinkStatus(this.apartmentId())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.status.set(status);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.error.set('Impossible de charger le lien de candidature.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
publish(): void {
|
||||
if (this.publishing()) return;
|
||||
this.error.set('');
|
||||
this.compatibilityHelp.set('');
|
||||
if (this.status()?.vaultActive) {
|
||||
this.sendPublication();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data: {
|
||||
title: 'Protéger les candidatures avec une Passkey ?',
|
||||
message:
|
||||
'Les candidatures contiendront des données sensibles. Votre navigateur va créer un coffre chiffré et protéger sa clé privée avec une Passkey. Cette Passkey sera nécessaire pour lire les dossiers : sans elle, les données seront irrécupérables. Aucune clé de secours, aucun PIN et aucune copie serveur ne seront créés.',
|
||||
confirmLabel: 'Créer la Passkey et publier',
|
||||
},
|
||||
role: 'alertdialog',
|
||||
width: 'min(36rem, calc(100vw - 2rem))',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
if (!confirmed) return;
|
||||
this.publishing.set(true);
|
||||
void this.passkeys
|
||||
.enroll('Passkey principale')
|
||||
.then((activation) => this.sendPublication(activation))
|
||||
.catch((failure: unknown) => this.handlePasskeyFailure(failure));
|
||||
});
|
||||
}
|
||||
|
||||
private sendPublication(
|
||||
activation?: Parameters<RentalApplicationService['publishLink']>[1],
|
||||
): void {
|
||||
this.publishing.set(true);
|
||||
this.applications
|
||||
.publishLink(this.apartmentId(), activation)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.status.set(status);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.publishing.set(false);
|
||||
this.error.set('Le lien de candidature n’a pas pu être publié. Réessayez.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handlePasskeyFailure(failure: unknown): void {
|
||||
this.publishing.set(false);
|
||||
if (failure instanceof ApplicationPasskeyError && failure.code === 'cancelled') {
|
||||
this.compatibilityHelp.set(
|
||||
'Création annulée : aucun coffre ni lien n’a été créé. Vous pouvez réessayer quand vous êtes prêt.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.compatibilityHelp.set(
|
||||
'Cet appareil n’a pas fourni la fonction PRF requise par le chiffrement. Essayez un navigateur récent et une Passkey compatible ; les autres fonctions restent disponibles.',
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<section class="privacy-notice" aria-labelledby="application-privacy-title">
|
||||
<header>
|
||||
<span class="material-icons" aria-hidden="true">encrypted</span>
|
||||
<div>
|
||||
<p class="eyebrow">Vos données restent confidentielles</p>
|
||||
<h2 id="application-privacy-title">Notice d’information</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="summary">
|
||||
Votre dossier est chiffré dans ce navigateur pour {{ landlordName() }}. Gardez votre lien
|
||||
privé : il est indispensable pour reprendre votre candidature et ne peut pas être
|
||||
récupéré.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>Lire la notice complète</summary>
|
||||
<div class="notice-content">
|
||||
<h3>Responsable et finalités</h3>
|
||||
<p>
|
||||
{{ landlordName() }} est responsable du traitement de votre candidature locative
|
||||
@if (landlordEmail()) {
|
||||
et peut être contacté à
|
||||
<a [href]="'mailto:' + landlordEmail()">{{ landlordEmail() }}</a>
|
||||
}
|
||||
. Vos informations servent uniquement à étudier le dossier, apprécier sa
|
||||
solvabilité, échanger avec vous et, le cas échéant, préparer la location. Ce
|
||||
traitement relève des mesures précontractuelles demandées par le candidat ;
|
||||
aucun consentement obligatoire n’est demandé.
|
||||
</p>
|
||||
|
||||
<h3>Données, justificatifs et destinataires</h3>
|
||||
<p>
|
||||
Le dossier peut contenir votre identité et vos coordonnées, votre situation
|
||||
personnelle, professionnelle et financière, votre recherche de logement, vos
|
||||
garanties ainsi que les justificatifs que vous choisissez de transmettre. Ils sont
|
||||
destinés au seul bailleur. Le service héberge leur version chiffrée et des
|
||||
métadonnées techniques minimales, sans disposer de la clé permettant de lire le
|
||||
contenu.
|
||||
</p>
|
||||
|
||||
<h3>Durée, sécurité et limites</h3>
|
||||
<p>
|
||||
Une candidature ouverte expire après 90 jours sans modification. Les candidatures
|
||||
sélectionnées, rejetées ou retirées sont supprimées au plus tard 90 jours après ce
|
||||
statut. Le chiffrement réduit l’exposition du contenu stocké mais ne remplace ni
|
||||
l’autorisation ni la suppression. Le service ne réalise pas d’analyse antivirus des
|
||||
fichiers. Révoquer un lien bloque les accès futurs au serveur, sans effacer les
|
||||
copies déjà enregistrées par son détenteur.
|
||||
</p>
|
||||
|
||||
<h3>Votre lien privé et vos droits</h3>
|
||||
<p>
|
||||
Le fragment de votre lien contient les secrets d’accès et de chiffrement. Il n’est
|
||||
pas envoyé au serveur : ne le partagez pas et conservez-le jusqu’à la fin de
|
||||
vos démarches. Ni le bailleur ni le service ne peuvent reconstruire un lien perdu.
|
||||
Pour demander l’accès, la rectification, l’effacement, la limitation ou l’opposition
|
||||
lorsque celle-ci s’applique, contactez le bailleur à l’adresse indiquée ci-dessus.
|
||||
Vous pouvez aussi saisir la CNIL.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
@use '../../../tokens' as tokens;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.privacy-notice {
|
||||
@include tokens.card(lg, surface-soft);
|
||||
container-type: inline-size;
|
||||
padding: tokens.space(5);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: tokens.space(3);
|
||||
|
||||
.material-icons {
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
place-items: center;
|
||||
border-radius: tokens.radius(md);
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: tokens.space(4) 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
details {
|
||||
border-block-start: 1px solid var(--border);
|
||||
padding-block-start: tokens.space(3);
|
||||
}
|
||||
|
||||
summary {
|
||||
min-height: 2.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
display: grid;
|
||||
gap: tokens.space(2);
|
||||
padding-block-start: tokens.space(3);
|
||||
|
||||
h3 {
|
||||
margin: tokens.space(2) 0 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-application-privacy-notice',
|
||||
templateUrl: './application-privacy-notice.html',
|
||||
styleUrl: './application-privacy-notice.scss',
|
||||
})
|
||||
export class ApplicationPrivacyNotice {
|
||||
readonly landlordName = input('le bailleur');
|
||||
readonly landlordEmail = input('');
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<article class="application-page">
|
||||
<header>
|
||||
<p class="eyebrow">Votre lien privé</p>
|
||||
<h1>{{ application()?.apartmentName || 'Candidature locative' }}</h1>
|
||||
<p>Conservez l’adresse complète de cette page pour reprendre votre dossier.</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<section class="application-state" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="34" />
|
||||
<strong>Ouverture sécurisée du dossier…</strong>
|
||||
</section>
|
||||
} @else if (error()) {
|
||||
<section class="application-state error-state" role="alert">
|
||||
<strong>Candidature indisponible</strong>
|
||||
<p>{{ error() }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="application-state ready-state" role="status">
|
||||
<span class="material-icons" aria-hidden="true">key</span>
|
||||
<div>
|
||||
<strong>Votre dossier privé est prêt</strong>
|
||||
<p>Vous pourrez renseigner et envoyer son contenu depuis cette page.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<app-application-privacy-notice
|
||||
[landlordName]="application()?.landlordName || 'le bailleur'"
|
||||
[landlordEmail]="application()?.landlordEmail || ''"
|
||||
/>
|
||||
</article>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
@use '../../../tokens' as tokens;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.application-page {
|
||||
display: grid;
|
||||
gap: tokens.space(6);
|
||||
|
||||
header h1 {
|
||||
margin-block-end: tokens.space(3);
|
||||
font-size: clamp(2rem, 8cqi, 3.25rem);
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
header > p:last-child,
|
||||
.application-state p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.application-state {
|
||||
@include tokens.card;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: tokens.space(4);
|
||||
min-height: 7rem;
|
||||
padding: tokens.space(5);
|
||||
|
||||
.material-icons {
|
||||
color: var(--accent);
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ready-state {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ApplicationCryptoService } from '../../services/application-crypto.service';
|
||||
import { RentalApplicationService } from '../../services/rental-application.service';
|
||||
import { CandidateApplicationPage } from './candidate-application-page';
|
||||
|
||||
describe('CandidateApplicationPage', () => {
|
||||
const application = {
|
||||
id: 'application-1',
|
||||
apartmentId: 'apt-1',
|
||||
state: 'DRAFT' as const,
|
||||
revision: 0,
|
||||
createdAt: '2026-09-04T10:00:00Z',
|
||||
lastActivityAt: '2026-09-04T10:00:00Z',
|
||||
encryptedApplicationKey: 'encrypted-key',
|
||||
cryptoVersion: 1,
|
||||
landlordName: 'Camille Martin',
|
||||
landlordEmail: 'camille@example.com',
|
||||
apartmentName: 'Studio République',
|
||||
};
|
||||
const applications = { resume: vi.fn(() => of(application)) };
|
||||
const crypto = {
|
||||
decode: vi.fn((value: string) =>
|
||||
value === 'valid-capability' || value === 'valid-key'
|
||||
? new Uint8Array(32)
|
||||
: new Uint8Array(),
|
||||
),
|
||||
};
|
||||
|
||||
it('sends only the capability to resume a valid private link', () => {
|
||||
const fixture = create(
|
||||
'application=application-1&capability=valid-capability&key=valid-key',
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(applications.resume).toHaveBeenCalledWith('application-1', 'valid-capability');
|
||||
expect(fixture.nativeElement.textContent).toContain('Votre dossier privé est prêt');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('valid-key');
|
||||
});
|
||||
|
||||
it('rejects an altered link before contacting the server', () => {
|
||||
const fixture = create('application=application-1&capability=invalid&key=valid-key');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(applications.resume).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'lien privé est incomplet ou a été altéré',
|
||||
);
|
||||
});
|
||||
|
||||
function create(fragment: string) {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: RentalApplicationService, useValue: applications },
|
||||
{ provide: ApplicationCryptoService, useValue: crypto },
|
||||
{ provide: ActivatedRoute, useValue: { snapshot: { fragment } } },
|
||||
],
|
||||
});
|
||||
return TestBed.createComponent(CandidateApplicationPage);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ApplicationCryptoService } from '../../services/application-crypto.service';
|
||||
import {
|
||||
CandidateApplication,
|
||||
RentalApplicationService,
|
||||
} from '../../services/rental-application.service';
|
||||
import { ApplicationPrivacyNotice } from '../application-privacy-notice/application-privacy-notice';
|
||||
|
||||
@Component({
|
||||
selector: 'app-candidate-application-page',
|
||||
imports: [ApplicationPrivacyNotice, MatProgressSpinnerModule],
|
||||
templateUrl: './candidate-application-page.html',
|
||||
styleUrl: './candidate-application-page.scss',
|
||||
})
|
||||
export class CandidateApplicationPage implements OnInit, OnDestroy {
|
||||
readonly application = signal<CandidateApplication | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
private readonly fragment = inject(ActivatedRoute).snapshot.fragment ?? '';
|
||||
private readonly applications = inject(RentalApplicationService);
|
||||
private readonly crypto = inject(ApplicationCryptoService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private applicationKey: Uint8Array<ArrayBuffer> | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const secrets = this.readSecrets();
|
||||
if (!secrets) {
|
||||
this.loading.set(false);
|
||||
this.error.set('Ce lien privé est incomplet ou a été altéré.');
|
||||
return;
|
||||
}
|
||||
this.applicationKey = secrets.key;
|
||||
this.applications
|
||||
.resume(secrets.applicationId, secrets.capability)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (application) => {
|
||||
this.application.set(application);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.clearApplicationKey();
|
||||
this.loading.set(false);
|
||||
this.error.set(
|
||||
'Cette candidature est indisponible. Vérifiez que le lien est complet.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearApplicationKey();
|
||||
}
|
||||
|
||||
private readSecrets(): {
|
||||
applicationId: string;
|
||||
capability: string;
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
} | null {
|
||||
const values = new URLSearchParams(this.fragment);
|
||||
const applicationId = values.get('application')?.trim() ?? '';
|
||||
const capability = values.get('capability') ?? '';
|
||||
const encodedKey = values.get('key') ?? '';
|
||||
if (!applicationId || applicationId.length > 200) return null;
|
||||
try {
|
||||
const capabilityBytes = this.crypto.decode(capability);
|
||||
const key = this.crypto.decode(encodedKey);
|
||||
return capabilityBytes.length === 32 && key.length === 32
|
||||
? { applicationId, capability, key }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearApplicationKey(): void {
|
||||
this.applicationKey?.fill(0);
|
||||
this.applicationKey = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<article class="application-page">
|
||||
<header class="application-heading">
|
||||
<p class="eyebrow">Candidature locative</p>
|
||||
<h1>{{ link()?.apartmentName || 'Préparation de votre dossier' }}</h1>
|
||||
@if (link(); as publicLink) {
|
||||
<p>Vous transmettez votre dossier directement à {{ publicLink.landlordName }}.</p>
|
||||
} @else {
|
||||
<p>Nous vérifions le lien avant de créer votre espace privé.</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (state() === 'loading' || state() === 'creating') {
|
||||
<section class="application-state" role="status" aria-live="polite">
|
||||
<mat-spinner diameter="34" />
|
||||
<div>
|
||||
<strong>{{
|
||||
state() === 'loading' ? 'Vérification du lien…' : 'Création du dossier chiffré…'
|
||||
}}</strong>
|
||||
<p>Ne fermez pas cette page.</p>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="application-state error-state" role="alert">
|
||||
<strong>Impossible de continuer</strong>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-flat-button type="button" class="primary-action" (click)="start()">
|
||||
Réessayer
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<app-application-privacy-notice
|
||||
[landlordName]="link()?.landlordName || 'le bailleur'"
|
||||
[landlordEmail]="link()?.landlordEmail || ''"
|
||||
/>
|
||||
</article>
|
||||
@@ -0,0 +1,45 @@
|
||||
@use '../../../tokens' as tokens;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.application-page {
|
||||
display: grid;
|
||||
gap: tokens.space(6);
|
||||
}
|
||||
|
||||
.application-heading {
|
||||
h1 {
|
||||
margin-block-end: tokens.space(3);
|
||||
font-size: clamp(2rem, 8cqi, 3.25rem);
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
> p:last-child {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.application-state {
|
||||
@include tokens.card;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: tokens.space(4);
|
||||
min-height: 7rem;
|
||||
padding: tokens.space(5);
|
||||
|
||||
p {
|
||||
margin: tokens.space(1) 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ApplicationCryptoService } from '../../services/application-crypto.service';
|
||||
import { RentalApplicationService } from '../../services/rental-application.service';
|
||||
import { PublicApplicationPage } from './public-application-page';
|
||||
|
||||
describe('PublicApplicationPage', () => {
|
||||
const link = {
|
||||
token: 'public-token',
|
||||
createdAt: '2026-09-04T10:00:00Z',
|
||||
landlordPublicKey: 'public-key',
|
||||
landlordName: 'Camille Martin',
|
||||
landlordEmail: 'camille@example.com',
|
||||
apartmentName: 'Studio République',
|
||||
};
|
||||
const secrets = {
|
||||
capability: 'candidate-capability',
|
||||
applicationKey: 'candidate-key',
|
||||
encryptedApplicationKey: { cryptoVersion: 1 as const, ciphertext: 'encrypted-key' },
|
||||
};
|
||||
const applications = {
|
||||
previewPublicLink: vi.fn(() => of(link)),
|
||||
createDraft: vi.fn(() => of({ id: 'application-1', state: 'DRAFT' as const, revision: 0 })),
|
||||
};
|
||||
const crypto = { createDraftSecrets: vi.fn().mockResolvedValue(secrets) };
|
||||
const router = { navigate: vi.fn().mockResolvedValue(true) };
|
||||
let fixture: ComponentFixture<PublicApplicationPage>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
applications.previewPublicLink.mockReturnValue(of(link));
|
||||
applications.createDraft.mockReturnValue(
|
||||
of({ id: 'application-1', state: 'DRAFT' as const, revision: 0 }),
|
||||
);
|
||||
crypto.createDraftSecrets.mockResolvedValue(secrets);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: RentalApplicationService, useValue: applications },
|
||||
{ provide: ApplicationCryptoService, useValue: crypto },
|
||||
{ provide: Router, useValue: router },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { paramMap: convertToParamMap({ token: 'public-token' }) },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(PublicApplicationPage);
|
||||
});
|
||||
|
||||
it('creates a distinct draft in the client and replaces the public URL with private secrets', async () => {
|
||||
fixture.detectChanges();
|
||||
await vi.waitFor(() => expect(router.navigate).toHaveBeenCalledOnce());
|
||||
|
||||
expect(crypto.createDraftSecrets).toHaveBeenCalledWith('public-key');
|
||||
expect(applications.createDraft).toHaveBeenCalledWith(
|
||||
'public-token',
|
||||
'candidate-capability',
|
||||
secrets.encryptedApplicationKey,
|
||||
);
|
||||
const [, options] = router.navigate.mock.calls[0];
|
||||
const fragment = new URLSearchParams(options.fragment);
|
||||
expect(fragment.get('application')).toBe('application-1');
|
||||
expect(fragment.get('capability')).toBe('candidate-capability');
|
||||
expect(fragment.get('key')).toBe('candidate-key');
|
||||
expect(options.replaceUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a truthful retryable failure without attempting draft creation when preview fails', () => {
|
||||
applications.previewPublicLink.mockReturnValue(throwError(() => new Error('Unavailable')));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(applications.createDraft).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de continuer');
|
||||
expect(fixture.nativeElement.textContent).toContain('Notice d’information');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { from, map, switchMap, tap } from 'rxjs';
|
||||
import { ApplicationCryptoService } from '../../services/application-crypto.service';
|
||||
import {
|
||||
PublicApplicationLink,
|
||||
RentalApplicationService,
|
||||
} from '../../services/rental-application.service';
|
||||
import { ApplicationPrivacyNotice } from '../application-privacy-notice/application-privacy-notice';
|
||||
|
||||
@Component({
|
||||
selector: 'app-public-application-page',
|
||||
imports: [ApplicationPrivacyNotice, MatButtonModule, MatProgressSpinnerModule],
|
||||
templateUrl: './public-application-page.html',
|
||||
styleUrl: './public-application-page.scss',
|
||||
})
|
||||
export class PublicApplicationPage implements OnInit {
|
||||
readonly link = signal<PublicApplicationLink | null>(null);
|
||||
readonly state = signal<'loading' | 'creating' | 'error'>('loading');
|
||||
readonly error = signal('');
|
||||
private readonly token = inject(ActivatedRoute).snapshot.paramMap.get('token') ?? '';
|
||||
private readonly applications = inject(RentalApplicationService);
|
||||
private readonly crypto = inject(ApplicationCryptoService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.start();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.state.set('loading');
|
||||
this.error.set('');
|
||||
this.applications
|
||||
.previewPublicLink(this.token)
|
||||
.pipe(
|
||||
tap((link) => {
|
||||
this.link.set(link);
|
||||
this.state.set('creating');
|
||||
}),
|
||||
switchMap((link) =>
|
||||
from(this.crypto.createDraftSecrets(link.landlordPublicKey)).pipe(
|
||||
map((secrets) => ({ link, secrets })),
|
||||
),
|
||||
),
|
||||
switchMap(({ secrets }) =>
|
||||
this.applications
|
||||
.createDraft(
|
||||
this.token,
|
||||
secrets.capability,
|
||||
secrets.encryptedApplicationKey,
|
||||
)
|
||||
.pipe(map((application) => ({ application, secrets }))),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ application, secrets }) => {
|
||||
const fragment = new URLSearchParams({
|
||||
application: application.id,
|
||||
capability: secrets.capability,
|
||||
key: secrets.applicationKey,
|
||||
}).toString();
|
||||
void this.router.navigate(['/candidatures/reprendre'], {
|
||||
fragment,
|
||||
replaceUrl: true,
|
||||
});
|
||||
},
|
||||
error: (failure: unknown) => {
|
||||
this.state.set('error');
|
||||
this.error.set(
|
||||
failure instanceof HttpErrorResponse && failure.status === 429
|
||||
? 'Trop de dossiers ont été ouverts depuis ce réseau. Réessayez plus tard.'
|
||||
: 'Ce lien est indisponible ou le dossier n’a pas pu être préparé.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { APPLICATION_CRYPTO, ApplicationCryptoService } from './application-crypto.service';
|
||||
|
||||
describe('ApplicationCryptoService', () => {
|
||||
let service: ApplicationCryptoService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: APPLICATION_CRYPTO, useValue: globalThis.crypto }],
|
||||
});
|
||||
service = TestBed.inject(ApplicationCryptoService);
|
||||
});
|
||||
|
||||
it('wraps the landlord private key and the application key without making them extractable', async () => {
|
||||
const prfOutput = service.randomBytes(32);
|
||||
const activation = await service.createVaultActivation(
|
||||
prfOutput,
|
||||
service.encode(service.randomBytes(32)),
|
||||
'Passkey principale',
|
||||
service.randomBytes(32),
|
||||
);
|
||||
expect(service.decode(activation.hkdfSalt)).toHaveLength(32);
|
||||
expect(service.decode(activation.nonce)).toHaveLength(12);
|
||||
expect(service.decode(activation.publicKey).length).toBeGreaterThanOrEqual(384);
|
||||
expect(service.decode(activation.encryptedPrivateKey).length).toBeGreaterThan(512);
|
||||
const privateKey = await stage(
|
||||
'unwrap landlord key',
|
||||
service.unwrapVaultPrivateKey(activation, prfOutput),
|
||||
);
|
||||
const draft = await stage(
|
||||
'create draft key',
|
||||
service.createDraftSecrets(activation.publicKey),
|
||||
);
|
||||
const clearApplicationKey = await stage(
|
||||
'decrypt application key',
|
||||
globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
privateKey,
|
||||
service.decode(draft.encryptedApplicationKey.ciphertext),
|
||||
),
|
||||
);
|
||||
|
||||
expect(privateKey.extractable).toBe(false);
|
||||
expect(service.encode(clearApplicationKey)).toBe(draft.applicationKey);
|
||||
expect(service.decode(draft.capability)).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('rejects a wrong PRF output and altered private-key envelopes', async () => {
|
||||
const prfOutput = service.randomBytes(32);
|
||||
const activation = await service.createVaultActivation(
|
||||
prfOutput,
|
||||
service.encode(service.randomBytes(32)),
|
||||
'Passkey principale',
|
||||
service.randomBytes(32),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.unwrapVaultPrivateKey(activation, service.randomBytes(32)),
|
||||
).rejects.toThrow();
|
||||
|
||||
const altered = service.decode(activation.encryptedPrivateKey);
|
||||
altered[0] ^= 1;
|
||||
await expect(
|
||||
service.unwrapVaultPrivateKey(
|
||||
{ ...activation, encryptedPrivateKey: service.encode(altered) },
|
||||
prfOutput,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
async function stage<T>(name: string, operation: Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation;
|
||||
} catch (failure) {
|
||||
throw new Error(name, { cause: failure });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
|
||||
export const APPLICATION_CRYPTO = new InjectionToken<Crypto>('APPLICATION_CRYPTO', {
|
||||
factory: () => globalThis.crypto,
|
||||
});
|
||||
|
||||
export interface ApplicationVaultActivation {
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
label: string;
|
||||
prfSalt: string;
|
||||
hkdfSalt: string;
|
||||
nonce: string;
|
||||
encryptedPrivateKey: string;
|
||||
cryptoVersion: 1;
|
||||
}
|
||||
|
||||
export interface ApplicationDraftSecrets {
|
||||
capability: string;
|
||||
applicationKey: string;
|
||||
encryptedApplicationKey: { cryptoVersion: 1; ciphertext: string };
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const vaultInfo = encoder.encode('lmnp-organizer:application-vault:v1');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationCryptoService {
|
||||
private readonly crypto = inject(APPLICATION_CRYPTO);
|
||||
|
||||
randomBytes(length: number): Uint8Array<ArrayBuffer> {
|
||||
return this.crypto.getRandomValues(new Uint8Array(length));
|
||||
}
|
||||
|
||||
encode(bytes: BufferSource): string {
|
||||
const view = ArrayBuffer.isView(bytes) ? asBytes(bytes) : new Uint8Array(bytes);
|
||||
let binary = '';
|
||||
for (const byte of view) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
decode(value: string): Uint8Array<ArrayBuffer> {
|
||||
const padded = value
|
||||
.replaceAll('-', '+')
|
||||
.replaceAll('_', '/')
|
||||
.padEnd(value.length + ((4 - (value.length % 4)) % 4), '=');
|
||||
try {
|
||||
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
} catch {
|
||||
throw new Error('Format cryptographique inconnu.');
|
||||
}
|
||||
}
|
||||
|
||||
async createVaultActivation(
|
||||
prfOutput: BufferSource,
|
||||
credentialId: string,
|
||||
label: string,
|
||||
prfSalt: Uint8Array<ArrayBuffer>,
|
||||
): Promise<ApplicationVaultActivation> {
|
||||
const keyPair = await this.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 3072,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
if (!isCryptoKeyPair(keyPair)) throw new Error('Impossible de créer le coffre.');
|
||||
|
||||
const [publicKey, privateKeyBytes] = await Promise.all([
|
||||
this.crypto.subtle.exportKey('spki', keyPair.publicKey),
|
||||
this.crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
|
||||
]);
|
||||
const hkdfSalt = this.randomBytes(32);
|
||||
const nonce = this.randomBytes(12);
|
||||
const wrappingKey = await this.wrappingKey(prfOutput, hkdfSalt);
|
||||
let encryptedPrivateKey: ArrayBuffer;
|
||||
try {
|
||||
encryptedPrivateKey = await this.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce, additionalData: vaultInfo, tagLength: 128 },
|
||||
wrappingKey,
|
||||
privateKeyBytes,
|
||||
);
|
||||
} finally {
|
||||
new Uint8Array(privateKeyBytes).fill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: this.encode(publicKey),
|
||||
credentialId,
|
||||
label: label.trim(),
|
||||
prfSalt: this.encode(prfSalt),
|
||||
hkdfSalt: this.encode(hkdfSalt),
|
||||
nonce: this.encode(nonce),
|
||||
encryptedPrivateKey: this.encode(encryptedPrivateKey),
|
||||
cryptoVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async unwrapVaultPrivateKey(
|
||||
activation: ApplicationVaultActivation,
|
||||
prfOutput: BufferSource,
|
||||
): Promise<CryptoKey> {
|
||||
if (activation.cryptoVersion !== 1) throw new Error('Format cryptographique inconnu.');
|
||||
const wrappingKey = await this.wrappingKey(prfOutput, this.decode(activation.hkdfSalt));
|
||||
const privateKey = await this.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: this.decode(activation.nonce),
|
||||
additionalData: vaultInfo,
|
||||
tagLength: 128,
|
||||
},
|
||||
wrappingKey,
|
||||
this.decode(activation.encryptedPrivateKey),
|
||||
);
|
||||
return this.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
privateKey,
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
async createDraftSecrets(landlordPublicKey: string): Promise<ApplicationDraftSecrets> {
|
||||
const publicKey = await this.crypto.subtle.importKey(
|
||||
'spki',
|
||||
this.decode(landlordPublicKey),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
const applicationKey = this.randomBytes(32);
|
||||
const capability = this.randomBytes(32);
|
||||
try {
|
||||
const encryptedApplicationKey = await this.crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
publicKey,
|
||||
applicationKey,
|
||||
);
|
||||
return {
|
||||
capability: this.encode(capability),
|
||||
applicationKey: this.encode(applicationKey),
|
||||
encryptedApplicationKey: {
|
||||
cryptoVersion: 1,
|
||||
ciphertext: this.encode(encryptedApplicationKey),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
applicationKey.fill(0);
|
||||
capability.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
private async wrappingKey(prfOutput: BufferSource, salt: BufferSource): Promise<CryptoKey> {
|
||||
const material = await this.crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [
|
||||
'deriveKey',
|
||||
]);
|
||||
return this.crypto.subtle.deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt, info: vaultInfo },
|
||||
material,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isCryptoKeyPair(value: CryptoKey | CryptoKeyPair): value is CryptoKeyPair {
|
||||
return 'publicKey' in value && 'privateKey' in value;
|
||||
}
|
||||
|
||||
function asBytes(value: ArrayBufferView<ArrayBuffer>): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { ApplicationCryptoService, ApplicationVaultActivation } from './application-crypto.service';
|
||||
import {
|
||||
APPLICATION_CREDENTIALS,
|
||||
ApplicationCredentials,
|
||||
ApplicationPasskeyError,
|
||||
ApplicationPasskeyService,
|
||||
} from './application-passkey.service';
|
||||
|
||||
describe('ApplicationPasskeyService', () => {
|
||||
const activation: ApplicationVaultActivation = {
|
||||
publicKey: 'public-key',
|
||||
credentialId: 'credential',
|
||||
label: 'Passkey principale',
|
||||
prfSalt: 'prf-salt',
|
||||
hkdfSalt: 'hkdf-salt',
|
||||
nonce: 'nonce',
|
||||
encryptedPrivateKey: 'private-key-envelope',
|
||||
cryptoVersion: 1,
|
||||
};
|
||||
const crypto = {
|
||||
randomBytes: vi.fn((length: number) => new Uint8Array(length).fill(1)),
|
||||
encode: vi.fn(() => 'credential'),
|
||||
createVaultActivation: vi.fn().mockResolvedValue(activation),
|
||||
};
|
||||
let credentials: ApplicationCredentials;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credentials = { create: vi.fn(), get: vi.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: APPLICATION_CREDENTIALS, useFactory: () => credentials },
|
||||
{ provide: ApplicationCryptoService, useValue: crypto },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the PRF result returned during passkey creation', async () => {
|
||||
const prfOutput = new Uint8Array(32).fill(2).buffer;
|
||||
vi.mocked(credentials.create).mockResolvedValue(credential(prfOutput));
|
||||
|
||||
await expect(TestBed.inject(ApplicationPasskeyService).enroll('Principale')).resolves.toBe(
|
||||
activation,
|
||||
);
|
||||
expect(credentials.get).not.toHaveBeenCalled();
|
||||
expect(crypto.createVaultActivation).toHaveBeenCalledWith(
|
||||
prfOutput,
|
||||
'credential',
|
||||
'Principale',
|
||||
expect.any(Uint8Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('completes PRF through an assertion when creation did not return it', async () => {
|
||||
vi.mocked(credentials.create).mockResolvedValue(credential());
|
||||
vi.mocked(credentials.get).mockResolvedValue(credential(new Uint8Array(32).fill(3).buffer));
|
||||
|
||||
await TestBed.inject(ApplicationPasskeyService).enroll('Principale');
|
||||
|
||||
expect(credentials.get).toHaveBeenCalledOnce();
|
||||
expect(crypto.createVaultActivation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fails closed when PRF is unavailable and distinguishes user cancellation', async () => {
|
||||
vi.mocked(credentials.create).mockResolvedValue(credential());
|
||||
vi.mocked(credentials.get).mockResolvedValue(credential());
|
||||
|
||||
await expect(
|
||||
TestBed.inject(ApplicationPasskeyService).enroll('Principale'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'prf_unsupported',
|
||||
});
|
||||
expect(crypto.createVaultActivation).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(credentials.create).mockRejectedValue(
|
||||
new DOMException('Cancelled', 'NotAllowedError'),
|
||||
);
|
||||
await expect(
|
||||
TestBed.inject(ApplicationPasskeyService).enroll('Principale'),
|
||||
).rejects.toEqual(new ApplicationPasskeyError('cancelled'));
|
||||
});
|
||||
});
|
||||
|
||||
function credential(first?: ArrayBuffer): Credential {
|
||||
return {
|
||||
id: 'credential',
|
||||
type: 'public-key',
|
||||
rawId: new Uint8Array(32).buffer,
|
||||
getClientExtensionResults: () => ({ prf: { results: first ? { first } : undefined } }),
|
||||
} as PublicKeyCredential;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { ApplicationCryptoService, ApplicationVaultActivation } from './application-crypto.service';
|
||||
|
||||
export interface ApplicationCredentials {
|
||||
create(options?: CredentialCreationOptions): Promise<Credential | null>;
|
||||
get(options?: CredentialRequestOptions): Promise<Credential | null>;
|
||||
}
|
||||
|
||||
export const APPLICATION_CREDENTIALS = new InjectionToken<ApplicationCredentials | null>(
|
||||
'APPLICATION_CREDENTIALS',
|
||||
{ factory: () => globalThis.navigator?.credentials ?? null },
|
||||
);
|
||||
|
||||
export type ApplicationPasskeyErrorCode = 'cancelled' | 'prf_unsupported' | 'failed';
|
||||
|
||||
export class ApplicationPasskeyError extends Error {
|
||||
constructor(readonly code: ApplicationPasskeyErrorCode) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationPasskeyService {
|
||||
private readonly credentials = inject(APPLICATION_CREDENTIALS);
|
||||
private readonly crypto = inject(ApplicationCryptoService);
|
||||
|
||||
async enroll(label: string): Promise<ApplicationVaultActivation> {
|
||||
if (!this.credentials) throw new ApplicationPasskeyError('prf_unsupported');
|
||||
const prfSalt = this.crypto.randomBytes(32);
|
||||
try {
|
||||
const created = requirePublicKeyCredential(
|
||||
await this.credentials.create({ publicKey: this.creationOptions(prfSalt) }),
|
||||
);
|
||||
const prfOutput =
|
||||
created.getClientExtensionResults().prf?.results?.first ??
|
||||
(await this.completePrf(created, prfSalt));
|
||||
if (!prfOutput) throw new ApplicationPasskeyError('prf_unsupported');
|
||||
try {
|
||||
return await this.crypto.createVaultActivation(
|
||||
prfOutput,
|
||||
this.crypto.encode(created.rawId),
|
||||
label,
|
||||
prfSalt,
|
||||
);
|
||||
} finally {
|
||||
clearBytes(prfOutput);
|
||||
}
|
||||
} catch (failure) {
|
||||
if (failure instanceof ApplicationPasskeyError) throw failure;
|
||||
if (failure instanceof DOMException && failure.name === 'NotAllowedError') {
|
||||
throw new ApplicationPasskeyError('cancelled');
|
||||
}
|
||||
throw new ApplicationPasskeyError('failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async completePrf(
|
||||
created: PrfPublicKeyCredential,
|
||||
prfSalt: Uint8Array<ArrayBuffer>,
|
||||
): Promise<BufferSource | undefined> {
|
||||
if (!this.credentials) return undefined;
|
||||
const asserted = requirePublicKeyCredential(
|
||||
await this.credentials.get({
|
||||
publicKey: {
|
||||
challenge: this.crypto.randomBytes(32),
|
||||
allowCredentials: [{ id: created.rawId, type: 'public-key' }],
|
||||
userVerification: 'required',
|
||||
extensions: { prf: { eval: { first: prfSalt } } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return asserted.getClientExtensionResults().prf?.results?.first;
|
||||
}
|
||||
|
||||
private creationOptions(prfSalt: Uint8Array<ArrayBuffer>): PublicKeyCredentialCreationOptions {
|
||||
return {
|
||||
challenge: this.crypto.randomBytes(32),
|
||||
rp: { name: 'LMNP Organizer' },
|
||||
user: {
|
||||
id: this.crypto.randomBytes(32),
|
||||
name: 'candidatures',
|
||||
displayName: 'Coffre de candidatures',
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ type: 'public-key', alg: -7 },
|
||||
{ type: 'public-key', alg: -257 },
|
||||
],
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
attestation: 'none',
|
||||
timeout: 120_000,
|
||||
extensions: { prf: { eval: { first: prfSalt } } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface PrfPublicKeyCredential extends Credential {
|
||||
readonly rawId: ArrayBuffer;
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
|
||||
function requirePublicKeyCredential(value: Credential | null): PrfPublicKeyCredential {
|
||||
if (!isPublicKeyCredential(value)) {
|
||||
throw new ApplicationPasskeyError('failed');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPublicKeyCredential(value: Credential | null): value is PrfPublicKeyCredential {
|
||||
return Boolean(
|
||||
value &&
|
||||
value.type === 'public-key' &&
|
||||
'rawId' in value &&
|
||||
value.rawId instanceof ArrayBuffer &&
|
||||
'getClientExtensionResults' in value &&
|
||||
typeof value.getClientExtensionResults === 'function',
|
||||
);
|
||||
}
|
||||
|
||||
function clearBytes(value: BufferSource): void {
|
||||
const bytes = ArrayBuffer.isView(value)
|
||||
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
: new Uint8Array(value);
|
||||
bytes.fill(0);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RentalApplicationService } from './rental-application.service';
|
||||
|
||||
describe('RentalApplicationService', () => {
|
||||
let http: HttpTestingController;
|
||||
let service: RentalApplicationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
service = TestBed.inject(RentalApplicationService);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('keeps public, capability and landlord routes distinct', () => {
|
||||
service.getLinkStatus('apt-1').subscribe();
|
||||
http.expectOne('/api/application-links/apartments/apt-1').flush({
|
||||
vaultActive: false,
|
||||
passkeyCount: 0,
|
||||
activeLink: null,
|
||||
});
|
||||
|
||||
service.previewPublicLink('public-token').subscribe();
|
||||
http.expectOne('/api/public/application-links/public-token').flush({});
|
||||
|
||||
service
|
||||
.createDraft('public-token', 'secret-capability', {
|
||||
cryptoVersion: 1,
|
||||
ciphertext: 'encrypted-key',
|
||||
})
|
||||
.subscribe();
|
||||
const create = http.expectOne('/api/public/application-links/public-token/applications');
|
||||
expect(create.request.headers.get('Authorization')).toBe('Application secret-capability');
|
||||
create.flush({ id: 'application-1', state: 'DRAFT', revision: 0 });
|
||||
|
||||
service.resume('application-1', 'secret-capability').subscribe();
|
||||
const resume = http.expectOne('/api/candidate/applications/application-1');
|
||||
expect(resume.request.headers.get('Authorization')).toBe('Application secret-capability');
|
||||
resume.flush({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApplicationVaultActivation } from './application-crypto.service';
|
||||
|
||||
export interface ActiveApplicationLink {
|
||||
token: string;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApplicationLinkStatus {
|
||||
vaultActive: boolean;
|
||||
passkeyCount: number;
|
||||
activeLink: ActiveApplicationLink | null;
|
||||
}
|
||||
|
||||
export interface PublicApplicationLink {
|
||||
token: string;
|
||||
createdAt: string;
|
||||
landlordPublicKey: string;
|
||||
landlordName: string;
|
||||
landlordEmail: string;
|
||||
apartmentName: string;
|
||||
}
|
||||
|
||||
export interface CandidateApplication {
|
||||
id: string;
|
||||
apartmentId: string;
|
||||
state: 'DRAFT';
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
encryptedApplicationKey: string;
|
||||
cryptoVersion: number;
|
||||
landlordName: string;
|
||||
landlordEmail: string;
|
||||
apartmentName: string;
|
||||
}
|
||||
|
||||
export interface CreatedApplicationDraft {
|
||||
id: string;
|
||||
state: 'DRAFT';
|
||||
revision: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RentalApplicationService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getLinkStatus(apartmentId: string): Observable<ApplicationLinkStatus> {
|
||||
return this.http.get<ApplicationLinkStatus>(
|
||||
`/api/application-links/apartments/${apartmentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
publishLink(
|
||||
apartmentId: string,
|
||||
activation?: ApplicationVaultActivation,
|
||||
): Observable<ApplicationLinkStatus> {
|
||||
return this.http.post<ApplicationLinkStatus>(
|
||||
`/api/application-links/apartments/${apartmentId}`,
|
||||
activation ? { activation } : null,
|
||||
);
|
||||
}
|
||||
|
||||
previewPublicLink(token: string): Observable<PublicApplicationLink> {
|
||||
return this.http.get<PublicApplicationLink>(`/api/public/application-links/${token}`);
|
||||
}
|
||||
|
||||
createDraft(
|
||||
token: string,
|
||||
capability: string,
|
||||
encryptedApplicationKey: { cryptoVersion: 1; ciphertext: string },
|
||||
): Observable<CreatedApplicationDraft> {
|
||||
return this.http.post<CreatedApplicationDraft>(
|
||||
`/api/public/application-links/${token}/applications`,
|
||||
{ encryptedApplicationKey },
|
||||
{ headers: new HttpHeaders({ Authorization: `Application ${capability}` }) },
|
||||
);
|
||||
}
|
||||
|
||||
resume(applicationId: string, capability: string): Observable<CandidateApplication> {
|
||||
return this.http.get<CandidateApplication>(`/api/candidate/applications/${applicationId}`, {
|
||||
headers: new HttpHeaders({ Authorization: `Application ${capability}` }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<title>LmnpFrontend</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<script>
|
||||
try {
|
||||
const preference = localStorage.getItem('lmnp-theme');
|
||||
|
||||
Reference in new issue
Block a user