From f41c4111a999c77da382223a4313366302eeb61e Mon Sep 17 00:00:00 2001 From: SethTheMountain Date: Sun, 4 Oct 2026 14:57:32 -0400 Subject: [PATCH 1/4] Replace HTTP Basic with identity-service JWT resource server --- pom.xml | 4 ++ .../fleetcheck/controller/AuthController.java | 24 +++++-- .../java/com/fleetcheck/domain/Account.java | 14 +++- .../java/com/fleetcheck/dto/AccountDTO.java | 6 +- .../repository/AccountRepository.java | 2 + .../AccountJwtAuthenticationConverter.java | 70 ++++++++++++++++++ .../security/AccountUserDetailsService.java | 31 -------- .../fleetcheck/security/SecurityConfig.java | 11 ++- .../java/com/fleetcheck/seed/DataSeeder.java | 12 ++-- .../com/fleetcheck/seed/ProdAdminSeeder.java | 36 ++++++---- .../fleetcheck/service/AccountService.java | 35 +++++---- src/main/resources/application.yml | 5 ++ .../SecurityAuthorizationIntegrationTest.java | 4 +- .../service/AccountServiceTest.java | 71 ++++++++++--------- src/test/resources/application.yml | 5 ++ 15 files changed, 209 insertions(+), 121 deletions(-) create mode 100644 src/main/java/com/fleetcheck/security/AccountJwtAuthenticationConverter.java delete mode 100644 src/main/java/com/fleetcheck/security/AccountUserDetailsService.java diff --git a/pom.xml b/pom.xml index 672a3d1..0260fad 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.security spring-security-test diff --git a/src/main/java/com/fleetcheck/controller/AuthController.java b/src/main/java/com/fleetcheck/controller/AuthController.java index b0dc0ba..3155c2c 100644 --- a/src/main/java/com/fleetcheck/controller/AuthController.java +++ b/src/main/java/com/fleetcheck/controller/AuthController.java @@ -4,11 +4,15 @@ import com.fleetcheck.domain.Driver; import com.fleetcheck.dto.CurrentUserDTO; import com.fleetcheck.repository.AccountRepository; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; +import java.util.Optional; + @RestController public class AuthController { @@ -19,18 +23,26 @@ public AuthController(AccountRepository accountRepository) { } @GetMapping("/api/me") - public CurrentUserDTO me(Authentication authentication) { - String role = authentication.getAuthorities().stream() - .findFirst() + public ResponseEntity me(Authentication authentication) { + Optional role = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) - .map(a -> a.replace("ROLE_", "")) - .orElse("UNKNOWN"); + .filter(a -> a.startsWith("ROLE_")) + .map(a -> a.substring("ROLE_".length())) + .findFirst(); + + // Signed in to identity-service but no active FleetCheck account for this email. + if (role.isEmpty()) { + return ResponseEntity.status(403).body(Map.of( + "status", 403, + "error", "Forbidden", + "message", "This account isn't authorized to use FleetCheck.")); + } Long driverId = accountRepository.findByUsername(authentication.getName()) .map(Account::getDriver) .map(Driver::getId) .orElse(null); - return new CurrentUserDTO(authentication.getName(), role, driverId); + return ResponseEntity.ok(new CurrentUserDTO(authentication.getName(), role.get(), driverId)); } } diff --git a/src/main/java/com/fleetcheck/domain/Account.java b/src/main/java/com/fleetcheck/domain/Account.java index 1a252e6..bd49f7e 100644 --- a/src/main/java/com/fleetcheck/domain/Account.java +++ b/src/main/java/com/fleetcheck/domain/Account.java @@ -6,7 +6,9 @@ @Entity @Table(name = "accounts", uniqueConstraints = { - @UniqueConstraint(name = "uk_account_username", columnNames = "username") + @UniqueConstraint(name = "uk_account_username", columnNames = "username"), + @UniqueConstraint(name = "uk_account_email", columnNames = "email"), + @UniqueConstraint(name = "uk_account_identity_sub", columnNames = "identity_sub") }) @Getter @Setter @@ -19,11 +21,17 @@ public class Account { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // Display name only. Authentication happens in identity-service. @Column(nullable = false, length = 50) private String username; - @Column(nullable = false) - private String password; + // Stored lowercase. Nullable so rows created before SSO survive the schema update. + @Column(length = 255) + private String email; + + // The identity-service subject. Set on the first successful login by email. + @Column(name = "identity_sub", length = 36) + private String identitySub; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) diff --git a/src/main/java/com/fleetcheck/dto/AccountDTO.java b/src/main/java/com/fleetcheck/dto/AccountDTO.java index 0edc11c..bc4a23c 100644 --- a/src/main/java/com/fleetcheck/dto/AccountDTO.java +++ b/src/main/java/com/fleetcheck/dto/AccountDTO.java @@ -2,23 +2,23 @@ import com.fleetcheck.domain.Account; import com.fleetcheck.domain.enums.Role; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record AccountDTO( Long id, @NotBlank String username, - String password, + @NotBlank @Email String email, @NotNull Role role, Long driverId, boolean active ) { - // password is intentionally write-only — never populated on read, even as a hash. public static AccountDTO fromEntity(Account account) { return new AccountDTO( account.getId(), account.getUsername(), - null, + account.getEmail(), account.getRole(), account.getDriver() != null ? account.getDriver().getId() : null, account.isActive() diff --git a/src/main/java/com/fleetcheck/repository/AccountRepository.java b/src/main/java/com/fleetcheck/repository/AccountRepository.java index 5be2ed6..6793792 100644 --- a/src/main/java/com/fleetcheck/repository/AccountRepository.java +++ b/src/main/java/com/fleetcheck/repository/AccountRepository.java @@ -7,4 +7,6 @@ public interface AccountRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); + Optional findByIdentitySub(String identitySub); } diff --git a/src/main/java/com/fleetcheck/security/AccountJwtAuthenticationConverter.java b/src/main/java/com/fleetcheck/security/AccountJwtAuthenticationConverter.java new file mode 100644 index 0000000..5d0a9bf --- /dev/null +++ b/src/main/java/com/fleetcheck/security/AccountJwtAuthenticationConverter.java @@ -0,0 +1,70 @@ +package com.fleetcheck.security; + +import com.fleetcheck.domain.Account; +import com.fleetcheck.repository.AccountRepository; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Turns a verified identity-service access token into a FleetCheck authentication. + * Roles live in FleetCheck's accounts table, never in the token. A valid token with no + * active matching account authenticates with NO authorities, so every protected route + * rejects it with 403. + */ +@Component +public class AccountJwtAuthenticationConverter implements Converter { + + private final AccountRepository accountRepository; + + public AccountJwtAuthenticationConverter(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + if (!"access".equals(jwt.getClaimAsString("type"))) { + throw new InvalidBearerTokenException("Only access tokens are accepted."); + } + String sub = jwt.getSubject(); + Optional account = resolve(sub, jwt.getClaimAsString("email")); + + if (account.isEmpty() || !account.get().isActive()) { + return new JwtAuthenticationToken(jwt, List.of(), sub); + } + Account a = account.get(); + return new JwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_" + a.getRole().name())), + a.getUsername()); + } + + private Optional resolve(String sub, String email) { + Optional bySub = accountRepository.findByIdentitySub(sub); + if (bySub.isPresent()) { + return bySub; + } + if (email == null || email.isBlank()) { + return Optional.empty(); + } + Optional byEmail = accountRepository.findByEmail(email.trim().toLowerCase(Locale.ROOT)); + if (byEmail.isEmpty()) { + return Optional.empty(); + } + Account account = byEmail.get(); + if (account.getIdentitySub() != null) { + // Already linked to a different identity. Never re-link. + return Optional.empty(); + } + account.setIdentitySub(sub); + return Optional.of(accountRepository.save(account)); + } +} diff --git a/src/main/java/com/fleetcheck/security/AccountUserDetailsService.java b/src/main/java/com/fleetcheck/security/AccountUserDetailsService.java deleted file mode 100644 index 030a5e9..0000000 --- a/src/main/java/com/fleetcheck/security/AccountUserDetailsService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.fleetcheck.security; - -import com.fleetcheck.domain.Account; -import com.fleetcheck.repository.AccountRepository; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -@Service -public class AccountUserDetailsService implements UserDetailsService { - - private final AccountRepository accountRepository; - - public AccountUserDetailsService(AccountRepository accountRepository) { - this.accountRepository = accountRepository; - } - - @Override - public UserDetails loadUserByUsername(String username) { - Account account = accountRepository.findByUsername(username) - .orElseThrow(() -> new UsernameNotFoundException("No account found for username: " + username)); - - return org.springframework.security.core.userdetails.User - .withUsername(account.getUsername()) - .password(account.getPassword()) - .authorities("ROLE_" + account.getRole().name()) - .disabled(!account.isActive()) - .build(); - } -} diff --git a/src/main/java/com/fleetcheck/security/SecurityConfig.java b/src/main/java/com/fleetcheck/security/SecurityConfig.java index 6ae7c26..25370c3 100644 --- a/src/main/java/com/fleetcheck/security/SecurityConfig.java +++ b/src/main/java/com/fleetcheck/security/SecurityConfig.java @@ -32,7 +32,8 @@ public AuthenticationEntryPoint authenticationEntryPoint() { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, + AccountJwtAuthenticationConverter jwtConverter) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) @@ -42,6 +43,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/me").authenticated() .requestMatchers("/api/accounts/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/inspection-reports/**").hasAnyRole("MECHANIC", "FLEET_MANAGER", "ADMIN") @@ -68,10 +70,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.PUT, "/api/drivers/**").hasRole("FLEET_MANAGER") .requestMatchers(HttpMethod.DELETE, "/api/drivers/**").hasRole("FLEET_MANAGER") - .requestMatchers(HttpMethod.GET, "/api/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/**") + .hasAnyRole("DRIVER", "MECHANIC", "FLEET_MANAGER", "ADMIN") .anyRequest().authenticated() ) - .httpBasic(basic -> basic.authenticationEntryPoint(authenticationEntryPoint())); + .oauth2ResourceServer(oauth -> oauth + .authenticationEntryPoint(authenticationEntryPoint()) + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter))); return http.build(); } diff --git a/src/main/java/com/fleetcheck/seed/DataSeeder.java b/src/main/java/com/fleetcheck/seed/DataSeeder.java index 473e110..66a09ee 100644 --- a/src/main/java/com/fleetcheck/seed/DataSeeder.java +++ b/src/main/java/com/fleetcheck/seed/DataSeeder.java @@ -88,23 +88,23 @@ public void run(String... args) { .firstName("Devon").lastName("Palmer").employeeId("E11002").active(true).build()); accountRepository.save(Account.builder() - .username("driver1").password(passwordEncoder.encode("password123")) + .username("driver1").email("driver1@demo.fleetcheck.local") .role(Role.DRIVER).driver(d1).active(true).build()); accountRepository.save(Account.builder() - .username("driver2").password(passwordEncoder.encode("password123")) + .username("driver2").email("driver2@demo.fleetcheck.local") .role(Role.DRIVER).driver(d2).active(true).build()); accountRepository.save(Account.builder() - .username("mechanic1").password(passwordEncoder.encode("password123")) + .username("mechanic1").email("mechanic1@demo.fleetcheck.local") .role(Role.MECHANIC).active(true).build()); accountRepository.save(Account.builder() - .username("manager1").password(passwordEncoder.encode("password123")) + .username("manager1").email("manager1@demo.fleetcheck.local") .role(Role.FLEET_MANAGER).active(true).build()); accountRepository.save(Account.builder() - .username("admin1").password(passwordEncoder.encode("password123")) + .username("admin1").email("admin1@demo.fleetcheck.local") .role(Role.ADMIN).active(true).build()); reportRepository.save(InspectionReport.builder() @@ -174,6 +174,6 @@ public void run(String... args) { .driverSignedAt(LocalDateTime.of(2026, 7, 15, 6, 40)).build()); System.out.println(">>> Seeded 5 vehicles, 4 drivers, 5 accounts, 5 inspection reports (all statuses), 3 damage markings"); - System.out.println(">>> Accounts: driver1/driver2/mechanic1/manager1/admin1 (password: password123)"); + System.out.println(">>> Accounts: driver1/driver2/mechanic1/manager1/admin1 (emails: @demo.fleetcheck.local)"); } } diff --git a/src/main/java/com/fleetcheck/seed/ProdAdminSeeder.java b/src/main/java/com/fleetcheck/seed/ProdAdminSeeder.java index a4d2f3f..2ea86b0 100644 --- a/src/main/java/com/fleetcheck/seed/ProdAdminSeeder.java +++ b/src/main/java/com/fleetcheck/seed/ProdAdminSeeder.java @@ -5,44 +5,52 @@ import com.fleetcheck.repository.AccountRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import java.util.Locale; + @Component @Profile("prod") public class ProdAdminSeeder implements CommandLineRunner { private final AccountRepository accountRepository; - private final PasswordEncoder passwordEncoder; - public ProdAdminSeeder(AccountRepository accountRepository, PasswordEncoder passwordEncoder) { + public ProdAdminSeeder(AccountRepository accountRepository) { this.accountRepository = accountRepository; - this.passwordEncoder = passwordEncoder; } @Override public void run(String... args) { - if (accountRepository.count() > 0) { + String raw = System.getenv("ADMIN_BOOTSTRAP_EMAIL"); + boolean hasEmail = raw != null && !raw.isBlank(); + + if (!hasEmail) { + if (accountRepository.count() == 0) { + throw new IllegalStateException( + "Production deployment has no accounts and ADMIN_BOOTSTRAP_EMAIL is not set. " + + "Set it to the identity-service email of the first admin and redeploy."); + } return; } - String username = System.getenv("ADMIN_BOOTSTRAP_USERNAME"); - String password = System.getenv("ADMIN_BOOTSTRAP_PASSWORD"); + String email = raw.trim().toLowerCase(Locale.ROOT); + if (accountRepository.findByEmail(email).isPresent()) { + return; + } - if (username == null || username.isBlank() || password == null || password.isBlank()) { - throw new IllegalStateException( - "Production deployment has no accounts and ADMIN_BOOTSTRAP_USERNAME / " + - "ADMIN_BOOTSTRAP_PASSWORD are not set. Set both environment variables and redeploy."); + String base = email.substring(0, email.indexOf('@') > 0 ? email.indexOf('@') : email.length()); + String username = base.length() > 40 ? base.substring(0, 40) : base; + if (accountRepository.findByUsername(username).isPresent()) { + username = username + "-admin"; } accountRepository.save(Account.builder() .username(username) - .password(passwordEncoder.encode(password)) + .email(email) .role(Role.ADMIN) .active(true) .build()); - System.out.println(">>> Bootstrap admin account created: " + username); - System.out.println(">>> Log in and use Accounts to create real driver/mechanic/manager logins."); + System.out.println(">>> Bootstrap admin created for " + email + ". Sign in through identity-service to link it."); } } diff --git a/src/main/java/com/fleetcheck/service/AccountService.java b/src/main/java/com/fleetcheck/service/AccountService.java index 09db165..4deaf72 100644 --- a/src/main/java/com/fleetcheck/service/AccountService.java +++ b/src/main/java/com/fleetcheck/service/AccountService.java @@ -4,29 +4,24 @@ import com.fleetcheck.domain.Driver; import com.fleetcheck.dto.AccountDTO; import com.fleetcheck.exception.DuplicateResourceException; -import com.fleetcheck.exception.InvalidRequestException; import com.fleetcheck.exception.ResourceNotFoundException; import com.fleetcheck.repository.AccountRepository; import com.fleetcheck.repository.DriverRepository; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Locale; @Service public class AccountService { private final AccountRepository accountRepository; private final DriverRepository driverRepository; - private final PasswordEncoder passwordEncoder; - public AccountService(AccountRepository accountRepository, - DriverRepository driverRepository, - PasswordEncoder passwordEncoder) { + public AccountService(AccountRepository accountRepository, DriverRepository driverRepository) { this.accountRepository = accountRepository; this.driverRepository = driverRepository; - this.passwordEncoder = passwordEncoder; } public List getAll() { @@ -37,15 +32,14 @@ public AccountDTO getById(Long id) { return AccountDTO.fromEntity(findOr404(id)); } + // Creates a role assignment. The person signs in through identity-service with this + // email, and the first login links their identity to this account. public AccountDTO create(AccountDTO dto) { - if (dto.password() == null || dto.password().isBlank()) { - throw new InvalidRequestException("password is required when creating an account."); - } Driver driver = dto.driverId() != null ? findDriverOr404(dto.driverId()) : null; Account account = Account.builder() .username(dto.username()) - .password(passwordEncoder.encode(dto.password())) + .email(normalize(dto.email())) .role(dto.role()) .driver(driver) .active(dto.active()) @@ -53,28 +47,33 @@ public AccountDTO create(AccountDTO dto) { try { return AccountDTO.fromEntity(accountRepository.save(account)); } catch (DataIntegrityViolationException e) { - throw new DuplicateResourceException( - "An account with username '" + dto.username() + "' already exists."); + throw duplicate(dto); } } public AccountDTO update(Long id, AccountDTO dto) { Account existing = findOr404(id); existing.setUsername(dto.username()); + existing.setEmail(normalize(dto.email())); existing.setRole(dto.role()); existing.setActive(dto.active()); existing.setDriver(dto.driverId() != null ? findDriverOr404(dto.driverId()) : null); - if (dto.password() != null && !dto.password().isBlank()) { - existing.setPassword(passwordEncoder.encode(dto.password())); - } try { return AccountDTO.fromEntity(accountRepository.save(existing)); } catch (DataIntegrityViolationException e) { - throw new DuplicateResourceException( - "An account with username '" + dto.username() + "' already exists."); + throw duplicate(dto); } } + private static String normalize(String email) { + return email == null ? null : email.trim().toLowerCase(Locale.ROOT); + } + + private static DuplicateResourceException duplicate(AccountDTO dto) { + return new DuplicateResourceException( + "An account with username '" + dto.username() + "' or email '" + dto.email() + "' already exists."); + } + private Account findOr404(Long id) { return accountRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Account", id)); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fbb9bbb..a81c4c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,9 @@ spring: + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${IDENTITY_JWKS_URI:http://localhost:8081/.well-known/jwks.json} application: name: fleetcheck datasource: diff --git a/src/test/java/com/fleetcheck/integration/SecurityAuthorizationIntegrationTest.java b/src/test/java/com/fleetcheck/integration/SecurityAuthorizationIntegrationTest.java index 3b17d7b..36f8797 100644 --- a/src/test/java/com/fleetcheck/integration/SecurityAuthorizationIntegrationTest.java +++ b/src/test/java/com/fleetcheck/integration/SecurityAuthorizationIntegrationTest.java @@ -99,7 +99,7 @@ void nonAdminRole_cannotCreateAccount() throws Exception { .with(user("manager1").roles("FLEET_MANAGER")) .contentType(MediaType.APPLICATION_JSON) .content(""" - {"username":"blockeduser","password":"secret123","role":"MECHANIC","active":true} + {"username":"blockeduser","email":"newmechanic@example.com","role":"MECHANIC","active":true} """)) .andExpect(status().isForbidden()); } @@ -110,7 +110,7 @@ void adminRole_canCreateAccount() throws Exception { .with(user("admin1").roles("ADMIN")) .contentType(MediaType.APPLICATION_JSON) .content(""" - {"username":"newmechanic","password":"secret123","role":"MECHANIC","active":true} + {"username":"newmechanic","email":"newmechanic@example.com","role":"MECHANIC","active":true} """)) .andExpect(status().isCreated()); } diff --git a/src/test/java/com/fleetcheck/service/AccountServiceTest.java b/src/test/java/com/fleetcheck/service/AccountServiceTest.java index b86941a..e3dec3f 100644 --- a/src/test/java/com/fleetcheck/service/AccountServiceTest.java +++ b/src/test/java/com/fleetcheck/service/AccountServiceTest.java @@ -5,7 +5,6 @@ import com.fleetcheck.domain.enums.Role; import com.fleetcheck.dto.AccountDTO; import com.fleetcheck.exception.DuplicateResourceException; -import com.fleetcheck.exception.InvalidRequestException; import com.fleetcheck.exception.ResourceNotFoundException; import com.fleetcheck.repository.AccountRepository; import com.fleetcheck.repository.DriverRepository; @@ -15,13 +14,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.when; @ExtendWith(MockitoExtension.class) @@ -29,79 +30,79 @@ class AccountServiceTest { @Mock private AccountRepository accountRepository; @Mock private DriverRepository driverRepository; - @Mock private PasswordEncoder passwordEncoder; private AccountService service; @BeforeEach void setUp() { - service = new AccountService(accountRepository, driverRepository, passwordEncoder); + service = new AccountService(accountRepository, driverRepository); } - private AccountDTO dto(String password, Role role, Long driverId) { - return new AccountDTO(null, "newuser", password, role, driverId, true); + private AccountDTO dto(String email, Role role, Long driverId) { + return new AccountDTO(null, "newuser", email, role, driverId, true); } @Test - void create_throwsInvalidRequestException_whenPasswordBlank() { - assertThrows(InvalidRequestException.class, () -> service.create(dto(" ", Role.DRIVER, null))); - } - - @Test - void create_hashesPasswordAndSaves_happyPath() { - when(passwordEncoder.encode("secret123")).thenReturn("HASHED"); + void create_normalizesEmailAndSaves_happyPath() { when(accountRepository.save(any(Account.class))).thenAnswer(inv -> inv.getArgument(0)); - AccountDTO result = service.create(dto("secret123", Role.MECHANIC, null)); + AccountDTO result = service.create(dto(" New.User@Example.COM ", Role.MECHANIC, null)); - assertThat(result.username()).isEqualTo("newuser"); + assertThat(result.email()).isEqualTo("new.user@example.com"); assertThat(result.role()).isEqualTo(Role.MECHANIC); - assertThat(result.password()).isNull(); + assertThat(result.driverId()).isNull(); } @Test - void create_linksDriver_whenDriverIdProvided() { - Driver driver = Driver.builder().id(5L).firstName("Test").lastName("Driver").employeeId("E1").active(true).build(); - when(driverRepository.findById(5L)).thenReturn(Optional.of(driver)); - when(passwordEncoder.encode("secret123")).thenReturn("HASHED"); + void create_linksDriver_whenDriverIdGiven() { + Driver driver = mock(Driver.class); + when(driver.getId()).thenReturn(7L); + when(driverRepository.findById(7L)).thenReturn(Optional.of(driver)); when(accountRepository.save(any(Account.class))).thenAnswer(inv -> inv.getArgument(0)); - AccountDTO result = service.create(dto("secret123", Role.DRIVER, 5L)); + AccountDTO result = service.create(dto("driver@example.com", Role.DRIVER, 7L)); - assertThat(result.driverId()).isEqualTo(5L); + assertThat(result.driverId()).isEqualTo(7L); } @Test - void create_throwsResourceNotFoundException_whenDriverIdMissing() { + void create_throwsResourceNotFound_whenDriverMissing() { when(driverRepository.findById(99L)).thenReturn(Optional.empty()); - assertThrows(ResourceNotFoundException.class, () -> service.create(dto("secret123", Role.DRIVER, 99L))); + assertThatThrownBy(() -> service.create(dto("driver@example.com", Role.DRIVER, 99L))) + .isInstanceOf(ResourceNotFoundException.class); + verify(accountRepository, never()).save(any()); } @Test - void create_throwsDuplicateResourceException_onUniqueConstraintViolation() { - when(passwordEncoder.encode("secret123")).thenReturn("HASHED"); + void create_throwsDuplicate_onConstraintViolation() { when(accountRepository.save(any(Account.class))).thenThrow(new DataIntegrityViolationException("dup")); - assertThrows(DuplicateResourceException.class, () -> service.create(dto("secret123", Role.MECHANIC, null))); + assertThatThrownBy(() -> service.create(dto("dup@example.com", Role.MECHANIC, null))) + .isInstanceOf(DuplicateResourceException.class); } @Test - void update_keepsExistingPassword_whenPasswordOmitted() { - Account existing = Account.builder().id(1L).username("olduser").password("OLD_HASH").role(Role.DRIVER).active(true).build(); + void update_changesFields_andKeepsIdentityLink() { + Account existing = Account.builder().id(1L).username("olduser").email("old@example.com") + .identitySub("sub-1").role(Role.DRIVER).active(true).build(); when(accountRepository.findById(1L)).thenReturn(Optional.of(existing)); when(accountRepository.save(any(Account.class))).thenAnswer(inv -> inv.getArgument(0)); - service.update(1L, dto(null, Role.MECHANIC, null)); + service.update(1L, new AccountDTO(1L, "newname", "New@Example.com", Role.MECHANIC, null, false)); - assertThat(existing.getPassword()).isEqualTo("OLD_HASH"); + assertThat(existing.getUsername()).isEqualTo("newname"); + assertThat(existing.getEmail()).isEqualTo("new@example.com"); assertThat(existing.getRole()).isEqualTo(Role.MECHANIC); + assertThat(existing.isActive()).isFalse(); + assertThat(existing.getIdentitySub()).isEqualTo("sub-1"); } @Test - void update_throwsResourceNotFoundException_whenAccountMissing() { - when(accountRepository.findById(1L)).thenReturn(Optional.empty()); + void update_throwsResourceNotFound_whenAccountMissing() { + when(accountRepository.findById(5L)).thenReturn(Optional.empty()); - assertThrows(ResourceNotFoundException.class, () -> service.update(1L, dto(null, Role.MECHANIC, null))); + assertThatThrownBy(() -> service.update(5L, dto("x@example.com", Role.DRIVER, null))) + .isInstanceOf(ResourceNotFoundException.class); } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 00bb2e2..ea25cc3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,4 +1,9 @@ spring: + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: http://localhost:8081/.well-known/jwks.json datasource: url: jdbc:h2:mem:fleetcheck-test;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver From 5e6127ccda0e1837ed6fdf286d10c2ab3dc62178 Mon Sep 17 00:00:00 2001 From: SethTheMountain Date: Sun, 4 Oct 2026 15:01:56 -0400 Subject: [PATCH 2/4] Remove unused PasswordEncoder --- src/main/java/com/fleetcheck/security/SecurityConfig.java | 7 ------- src/main/java/com/fleetcheck/seed/DataSeeder.java | 6 +----- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/main/java/com/fleetcheck/security/SecurityConfig.java b/src/main/java/com/fleetcheck/security/SecurityConfig.java index 25370c3..238cb19 100644 --- a/src/main/java/com/fleetcheck/security/SecurityConfig.java +++ b/src/main/java/com/fleetcheck/security/SecurityConfig.java @@ -7,8 +7,6 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; @@ -16,11 +14,6 @@ @EnableWebSecurity public class SecurityConfig { - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public AuthenticationEntryPoint authenticationEntryPoint() { return (request, response, authException) -> { diff --git a/src/main/java/com/fleetcheck/seed/DataSeeder.java b/src/main/java/com/fleetcheck/seed/DataSeeder.java index 66a09ee..1d678af 100644 --- a/src/main/java/com/fleetcheck/seed/DataSeeder.java +++ b/src/main/java/com/fleetcheck/seed/DataSeeder.java @@ -5,7 +5,6 @@ import com.fleetcheck.repository.*; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.time.LocalDate; @@ -21,22 +20,19 @@ public class DataSeeder implements CommandLineRunner { private final InspectionReportRepository reportRepository; private final RepairOrderRepository repairOrderRepository; private final DamageMarkingRepository damageMarkingRepository; - private final PasswordEncoder passwordEncoder; public DataSeeder(VehicleRepository vehicleRepository, DriverRepository driverRepository, AccountRepository accountRepository, InspectionReportRepository reportRepository, RepairOrderRepository repairOrderRepository, - DamageMarkingRepository damageMarkingRepository, - PasswordEncoder passwordEncoder) { + DamageMarkingRepository damageMarkingRepository) { this.vehicleRepository = vehicleRepository; this.driverRepository = driverRepository; this.accountRepository = accountRepository; this.reportRepository = reportRepository; this.repairOrderRepository = repairOrderRepository; this.damageMarkingRepository = damageMarkingRepository; - this.passwordEncoder = passwordEncoder; } @Override From b6e3a0e63b5eac7aad220acd5c0974039d06eb6f Mon Sep 17 00:00:00 2001 From: SethTheMountain Date: Sun, 4 Oct 2026 15:05:58 -0400 Subject: [PATCH 3/4] Frontend: sign in through identity-service with Bearer tokens and refresh --- frontend/src/api/client.test.ts | 163 ++++++++++++++++--------- frontend/src/api/client.ts | 28 +++-- frontend/src/api/types.ts | 1 + frontend/src/auth/AuthContext.test.tsx | 125 ++++++++++++++----- frontend/src/auth/AuthContext.tsx | 48 ++++---- frontend/src/auth/tokens.ts | 70 +++++++++++ frontend/src/pages/AccountsPage.tsx | 22 ++-- frontend/src/pages/LoginPage.tsx | 19 ++- 8 files changed, 336 insertions(+), 140 deletions(-) create mode 100644 frontend/src/auth/tokens.ts diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index b182214..5fca538 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { api, ApiError } from './client' +import { AUTH_EXPIRED_EVENT } from '../auth/tokens' + +const ACCESS_KEY = 'fleetcheck-access-token' +const REFRESH_KEY = 'fleetcheck-refresh-token' + +function jsonResponse(status: number, body: unknown = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => body, + } +} + +const unauthorized = { + ok: false, + status: 401, + headers: new Headers(), + json: async () => { + throw new Error('no body') + }, +} describe('api client', () => { beforeEach(() => { @@ -12,13 +34,7 @@ describe('api client', () => { }) it('sends GET requests and returns the parsed body', async () => { - const mockResponse = { - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ id: 1 }), - } - ;(fetch as any).mockResolvedValue(mockResponse) + ;(fetch as any).mockResolvedValue(jsonResponse(200, { id: 1 })) const result = await api.get('/vehicles') @@ -26,28 +42,18 @@ describe('api client', () => { expect(result).toEqual({ id: 1 }) }) - it('adds an Authorization header when credentials exist in sessionStorage', async () => { - sessionStorage.setItem('fleetcheck-credentials', 'ZHJpdmVyMTpwYXNzd29yZA==') - const mockResponse = { - ok: true, status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({}), - } - ;(fetch as any).mockResolvedValue(mockResponse) + it('adds a Bearer Authorization header when an access token is stored', async () => { + sessionStorage.setItem(ACCESS_KEY, 'tok123') + ;(fetch as any).mockResolvedValue(jsonResponse(200)) await api.get('/vehicles') const headers = (fetch as any).mock.calls[0][1].headers - expect(headers.Authorization).toBe('Basic ZHJpdmVyMTpwYXNzd29yZA==') + expect(headers.Authorization).toBe('Bearer tok123') }) - it('omits the Authorization header when no credentials are stored', async () => { - const mockResponse = { - ok: true, status: 200, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({}), - } - ;(fetch as any).mockResolvedValue(mockResponse) + it('omits the Authorization header when no token is stored', async () => { + ;(fetch as any).mockResolvedValue(jsonResponse(200)) await api.get('/vehicles') @@ -56,12 +62,7 @@ describe('api client', () => { }) it('serializes the request body for POST', async () => { - const mockResponse = { - ok: true, status: 201, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => ({ id: 5 }), - } - ;(fetch as any).mockResolvedValue(mockResponse) + ;(fetch as any).mockResolvedValue(jsonResponse(201, { id: 5 })) await api.post('/vehicles', { unitNumber: 'FRK-9999' }) @@ -71,46 +72,92 @@ describe('api client', () => { }) it('returns undefined for 204 No Content responses', async () => { - const mockResponse = { - ok: true, status: 204, + ;(fetch as any).mockResolvedValue({ + ok: true, + status: 204, headers: new Headers(), - json: async () => { throw new Error('should not be called') }, - } - ;(fetch as any).mockResolvedValue(mockResponse) + json: async () => { + throw new Error('should not be called') + }, + }) - const result = await api.delete('/vehicles/1') - - expect(result).toBeUndefined() + expect(await api.delete('/vehicles/1')).toBeUndefined() }) it('throws an ApiError with the server message on a JSON error response', async () => { - const errorBody = { - timestamp: '2026-01-01T00:00:00', - status: 404, - error: 'Not Found', - message: 'Vehicle not found with id: 999', - path: '/api/vehicles/999', - validationErrors: null, - } - const mockResponse = { - ok: false, status: 404, - headers: new Headers({ 'content-type': 'application/json' }), - json: async () => errorBody, - } - ;(fetch as any).mockResolvedValue(mockResponse) + ;(fetch as any).mockResolvedValue( + jsonResponse(404, { + timestamp: '2026-01-01T00:00:00', + status: 404, + error: 'Not Found', + message: 'Vehicle not found with id: 999', + path: '/api/vehicles/999', + validationErrors: null, + }), + ) await expect(api.get('/vehicles/999')).rejects.toThrow(ApiError) await expect(api.get('/vehicles/999')).rejects.toThrow('Vehicle not found with id: 999') }) - it('throws a generic Error when the failed response has no JSON body', async () => { - const mockResponse = { - ok: false, status: 401, - headers: new Headers(), - json: async () => { throw new Error('no body') }, - } - ;(fetch as any).mockResolvedValue(mockResponse) + it('throws a generic Error on a 401 with no stored session, without trying to refresh', async () => { + ;(fetch as any).mockResolvedValue(unauthorized) await expect(api.get('/vehicles')).rejects.toThrow('Request failed with status 401') + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('refreshes the tokens on a 401 and retries the request with the new access token', async () => { + sessionStorage.setItem(ACCESS_KEY, 'old') + sessionStorage.setItem(REFRESH_KEY, 'refresh1') + ;(fetch as any) + .mockResolvedValueOnce(unauthorized) + .mockResolvedValueOnce(jsonResponse(200, { accessToken: 'new', refreshToken: 'refresh2' })) + .mockResolvedValueOnce(jsonResponse(200, { id: 1 })) + + const result = await api.get('/vehicles') + + expect(result).toEqual({ id: 1 }) + const refreshCall = (fetch as any).mock.calls[1] + expect(refreshCall[0]).toContain('/auth/token/refresh') + expect(JSON.parse(refreshCall[1].body)).toEqual({ refreshToken: 'refresh1' }) + expect((fetch as any).mock.calls[2][1].headers.Authorization).toBe('Bearer new') + expect(sessionStorage.getItem(ACCESS_KEY)).toBe('new') + expect(sessionStorage.getItem(REFRESH_KEY)).toBe('refresh2') + }) + + it('clears the session and fires the expired event when the refresh fails', async () => { + sessionStorage.setItem(ACCESS_KEY, 'old') + sessionStorage.setItem(REFRESH_KEY, 'dead') + const onExpired = vi.fn() + window.addEventListener(AUTH_EXPIRED_EVENT, onExpired) + ;(fetch as any) + .mockResolvedValueOnce(unauthorized) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), json: async () => ({}) }) + + await expect(api.get('/vehicles')).rejects.toThrow() + + window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired) + expect(onExpired).toHaveBeenCalledTimes(1) + expect(sessionStorage.getItem(ACCESS_KEY)).toBeNull() + expect(sessionStorage.getItem(REFRESH_KEY)).toBeNull() + }) + + it('shares a single refresh request between concurrent 401s', async () => { + sessionStorage.setItem(ACCESS_KEY, 'old') + sessionStorage.setItem(REFRESH_KEY, 'refresh1') + let refreshCalls = 0 + ;(fetch as any).mockImplementation(async (url: string, init: RequestInit) => { + if (String(url).includes('/auth/token/refresh')) { + refreshCalls += 1 + return jsonResponse(200, { accessToken: 'new', refreshToken: 'refresh2' }) + } + const auth = (init.headers as Record).Authorization + return auth === 'Bearer new' ? jsonResponse(200, { ok: true }) : unauthorized + }) + + await Promise.all([api.get('/a'), api.get('/b')]) + + expect(refreshCalls).toBe(1) }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3c45916..0c389f9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { AUTH_EXPIRED_EVENT, clearTokens, getAccessToken, refreshTokens } from '../auth/tokens' + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api' export interface ErrorResponse { @@ -20,20 +22,28 @@ export class ApiError extends Error { } } -function getAuthHeader(): string | null { - const raw = sessionStorage.getItem('fleetcheck-credentials') - return raw ? `Basic ${raw}` : null -} - -export async function apiFetch(path: string, options: RequestInit = {}): Promise { - const authHeader = getAuthHeader() +async function send(path: string, options: RequestInit): Promise { + const token = getAccessToken() const headers: HeadersInit = { 'Content-Type': 'application/json', - ...(authHeader ? { Authorization: authHeader } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers, } + return fetch(`${API_BASE_URL}${path}`, { ...options, headers }) +} - const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers }) +export async function apiFetch(path: string, options: RequestInit = {}): Promise { + let response = await send(path, options) + + if (response.status === 401 && getAccessToken() !== null) { + if (await refreshTokens()) { + response = await send(path, options) + } + if (response.status === 401) { + clearTokens() + window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) + } + } if (response.status === 204) { return undefined as T diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3e33e5f..23f9e50 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -29,6 +29,7 @@ export interface Driver { export interface Account { id: number username: string + email: string role: Role driverId: number | null active: boolean diff --git a/frontend/src/auth/AuthContext.test.tsx b/frontend/src/auth/AuthContext.test.tsx index 4cb8b3a..0fd96d2 100644 --- a/frontend/src/auth/AuthContext.test.tsx +++ b/frontend/src/auth/AuthContext.test.tsx @@ -1,21 +1,45 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { render, screen, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AuthProvider, useAuth } from './AuthContext' -import { api } from '../api/client' +import { api, ApiError } from '../api/client' +import { AUTH_EXPIRED_EVENT, loginWithIdentity } from './tokens' + +vi.mock('../api/client', () => { + class ApiError extends Error { + status: number + validationErrors: string[] | null = null + constructor(body: { status: number; message: string }) { + super(body.message) + this.status = body.status + } + } + return { api: { get: vi.fn() }, ApiError } +}) + +vi.mock('./tokens', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, loginWithIdentity: vi.fn() } +}) + +const ACCESS_KEY = 'fleetcheck-access-token' +const REFRESH_KEY = 'fleetcheck-refresh-token' -vi.mock('../api/client', () => ({ - api: { get: vi.fn() }, -})) +function storeFakeTokens() { + sessionStorage.setItem(ACCESS_KEY, 'access') + sessionStorage.setItem(REFRESH_KEY, 'refresh') +} function TestConsumer() { const { user, isLoading, login, logout } = useAuth() + const [error, setError] = useState('') async function handleLogin() { try { - await login('driver1', 'password123') - } catch { - // LoginPage handles this in real usage; swallow here to isolate state assertions + await login('driver@example.com', 'pw') + } catch (err) { + setError(err instanceof Error ? err.message : 'unknown') } } @@ -23,69 +47,106 @@ function TestConsumer() {
{isLoading ? 'loading' : 'ready'} {user ? `${user.username}:${user.role}` : 'none'} + {error}
) } +const driver = { username: 'driver1', role: 'DRIVER', driverId: 1 } + +async function renderReady() { + render() + await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('ready')) +} + describe('AuthContext', () => { beforeEach(() => { sessionStorage.clear() vi.clearAllMocks() }) - it('starts with no user once initial loading finishes', async () => { - render() - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('ready')) + it('starts with no user and does not call /me when there is no session', async () => { + await renderReady() expect(screen.getByTestId('user')).toHaveTextContent('none') + expect(api.get).not.toHaveBeenCalled() }) - it('login stores credentials and sets the user on success', async () => { - (api.get as any).mockResolvedValue({ username: 'driver1', role: 'DRIVER', driverId: 1 }) - - render() - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('ready')) + it('login signs in through identity-service and loads the FleetCheck user', async () => { + ;(loginWithIdentity as any).mockImplementation(async () => storeFakeTokens()) + ;(api.get as any).mockResolvedValue(driver) + await renderReady() await userEvent.click(screen.getByText('login')) await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('driver1:DRIVER')) - expect(sessionStorage.getItem('fleetcheck-credentials')).not.toBeNull() + expect(loginWithIdentity).toHaveBeenCalledWith('driver@example.com', 'pw') + expect(sessionStorage.getItem(ACCESS_KEY)).not.toBeNull() }) - it('clears stored credentials and stays logged out when login fails', async () => { - (api.get as any).mockRejectedValue(new Error('401')) - - render() - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('ready')) + it('stays logged out and surfaces the message when identity-service rejects the login', async () => { + ;(loginWithIdentity as any).mockRejectedValue(new Error('Invalid email or password.')) + await renderReady() await userEvent.click(screen.getByText('login')) - await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('none')) - expect(sessionStorage.getItem('fleetcheck-credentials')).toBeNull() + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent('Invalid email or password.')) + expect(screen.getByTestId('user')).toHaveTextContent('none') + expect(api.get).not.toHaveBeenCalled() }) - it('logout clears storage and resets the user', async () => { - (api.get as any).mockResolvedValue({ username: 'driver1', role: 'DRIVER', driverId: 1 }) + it('shows the not-authorized message and clears tokens on a 403 from /me', async () => { + ;(loginWithIdentity as any).mockImplementation(async () => storeFakeTokens()) + ;(api.get as any).mockRejectedValue( + new (ApiError as any)({ status: 403, message: "This account isn't authorized to use FleetCheck." }), + ) + await renderReady() - render() - await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('ready')) + await userEvent.click(screen.getByText('login')) + + await waitFor(() => + expect(screen.getByTestId('error')).toHaveTextContent("This account isn't authorized to use FleetCheck."), + ) + expect(screen.getByTestId('user')).toHaveTextContent('none') + expect(sessionStorage.getItem(ACCESS_KEY)).toBeNull() + expect(sessionStorage.getItem(REFRESH_KEY)).toBeNull() + }) + + it('logout clears tokens and resets the user', async () => { + ;(loginWithIdentity as any).mockImplementation(async () => storeFakeTokens()) + ;(api.get as any).mockResolvedValue(driver) + await renderReady() await userEvent.click(screen.getByText('login')) await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('driver1:DRIVER')) await userEvent.click(screen.getByText('logout')) expect(screen.getByTestId('user')).toHaveTextContent('none') - expect(sessionStorage.getItem('fleetcheck-credentials')).toBeNull() + expect(sessionStorage.getItem(ACCESS_KEY)).toBeNull() + expect(sessionStorage.getItem(REFRESH_KEY)).toBeNull() }) - it('validates a previously-stored credential against /me on mount', async () => { - sessionStorage.setItem('fleetcheck-credentials', 'ZHJpdmVyMTpwYXNzd29yZA==') - ;(api.get as any).mockResolvedValue({ username: 'driver1', role: 'DRIVER', driverId: 1 }) + it('restores a stored session by validating it against /me on mount', async () => { + storeFakeTokens() + ;(api.get as any).mockResolvedValue(driver) render() await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('driver1:DRIVER')) expect(api.get).toHaveBeenCalledWith('/me') }) + + it('clears the user when the session-expired event fires', async () => { + storeFakeTokens() + ;(api.get as any).mockResolvedValue(driver) + render() + await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('driver1:DRIVER')) + + act(() => { + window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) + }) + + expect(screen.getByTestId('user')).toHaveTextContent('none') + }) }) diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 4f37404..b0e136c 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -1,53 +1,59 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' -import { api } from '../api/client' +import { api, ApiError } from '../api/client' +import { AUTH_EXPIRED_EVENT, clearTokens, hasSession, loginWithIdentity } from './tokens' interface CurrentUser { username: string - role: 'DRIVER' | 'MECHANIC' | 'FLEET_MANAGER' | string + role: 'DRIVER' | 'MECHANIC' | 'FLEET_MANAGER' | 'ADMIN' | string driverId: number | null } interface AuthContextValue { user: CurrentUser | null isLoading: boolean - login: (username: string, password: string) => Promise + login: (email: string, password: string) => Promise logout: () => void } const AuthContext = createContext(undefined) -const STORAGE_KEY = 'fleetcheck-credentials' export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) useEffect(() => { - const stored = sessionStorage.getItem(STORAGE_KEY) - if (!stored) { + function onExpired() { + setUser(null) + } + window.addEventListener(AUTH_EXPIRED_EVENT, onExpired) + + if (!hasSession()) { setIsLoading(false) - return + } else { + api + .get('/me') + .then(setUser) + .catch(() => clearTokens()) + .finally(() => setIsLoading(false)) } - api - .get('/me') - .then(setUser) - .catch(() => sessionStorage.removeItem(STORAGE_KEY)) - .finally(() => setIsLoading(false)) + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired) }, []) - async function login(username: string, password: string) { - const encoded = btoa(`${username}:${password}`) - sessionStorage.setItem(STORAGE_KEY, encoded) + async function login(email: string, password: string) { + await loginWithIdentity(email, password) try { - const currentUser = await api.get('/me') - setUser(currentUser) - } catch { - sessionStorage.removeItem(STORAGE_KEY) - throw new Error('Invalid username or password.') + setUser(await api.get('/me')) + } catch (err) { + clearTokens() + if (err instanceof ApiError && err.status === 403) { + throw new Error(err.message) + } + throw new Error('Could not load your FleetCheck account. Please try again.') } } function logout() { - sessionStorage.removeItem(STORAGE_KEY) + clearTokens() setUser(null) } diff --git a/frontend/src/auth/tokens.ts b/frontend/src/auth/tokens.ts new file mode 100644 index 0000000..97afcb6 --- /dev/null +++ b/frontend/src/auth/tokens.ts @@ -0,0 +1,70 @@ +const IDENTITY_URL = import.meta.env.VITE_IDENTITY_URL || 'http://localhost:8081' +const ACCESS_KEY = 'fleetcheck-access-token' +const REFRESH_KEY = 'fleetcheck-refresh-token' + +export const AUTH_EXPIRED_EVENT = 'fleetcheck-auth-expired' + +export function getAccessToken(): string | null { + return sessionStorage.getItem(ACCESS_KEY) +} + +export function hasSession(): boolean { + return sessionStorage.getItem(ACCESS_KEY) !== null || sessionStorage.getItem(REFRESH_KEY) !== null +} + +export function clearTokens(): void { + sessionStorage.removeItem(ACCESS_KEY) + sessionStorage.removeItem(REFRESH_KEY) +} + +function storeTokens(body: { accessToken: string; refreshToken: string }): void { + sessionStorage.setItem(ACCESS_KEY, body.accessToken) + sessionStorage.setItem(REFRESH_KEY, body.refreshToken) +} + +export async function loginWithIdentity(email: string, password: string): Promise { + let response: Response + try { + response = await fetch(`${IDENTITY_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim(), password }), + }) + } catch { + throw new Error('Could not reach the sign-in service. It may be waking up, so try again in a minute.') + } + if (response.status === 401 || response.status === 400) { + throw new Error('Invalid email or password.') + } + if (!response.ok) { + throw new Error('Sign-in failed. Please try again.') + } + storeTokens(await response.json()) +} + +let inFlight: Promise | null = null + +// Shared refresh: concurrent 401s trigger one request. Resolves true if new tokens were stored. +export function refreshTokens(): Promise { + if (inFlight) return inFlight + const refreshToken = sessionStorage.getItem(REFRESH_KEY) + if (!refreshToken) return Promise.resolve(false) + + inFlight = (async () => { + try { + const response = await fetch(`${IDENTITY_URL}/auth/token/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + if (!response.ok) return false + storeTokens(await response.json()) + return true + } catch { + return false + } finally { + inFlight = null + } + })() + return inFlight +} diff --git a/frontend/src/pages/AccountsPage.tsx b/frontend/src/pages/AccountsPage.tsx index 9b590e3..dd82259 100644 --- a/frontend/src/pages/AccountsPage.tsx +++ b/frontend/src/pages/AccountsPage.tsx @@ -17,7 +17,7 @@ export function AccountsPage() { const [loadError, setLoadError] = useState(null) const [username, setUsername] = useState('') - const [password, setPassword] = useState('') + const [email, setEmail] = useState('') const [role, setRole] = useState('') const [driverId, setDriverId] = useState('') @@ -50,7 +50,7 @@ export function AccountsPage() { function resetForm() { setUsername('') - setPassword('') + setEmail('') setRole('') setDriverId('') setSubmitError(null) @@ -69,7 +69,7 @@ export function AccountsPage() { try { await api.post('/accounts', { username: username.trim(), - password, + email: email.trim(), role, driverId: role === 'DRIVER' && driverId ? Number(driverId) : null, active: true, @@ -87,6 +87,7 @@ export function AccountsPage() { try { await api.put(`/accounts/${account.id}`, { username: account.username, + email: account.email, role: account.role, driverId: account.driverId, active: !account.active, @@ -107,6 +108,7 @@ export function AccountsPage() { try { await api.put(`/accounts/${account.id}`, { username: account.username, + email: account.email, role: account.role, driverId: Number(selected), active: account.active, @@ -146,14 +148,14 @@ export function AccountsPage() {
-
) From cdd3d032c51e26beec277c036a0b792fbaa0c14a Mon Sep 17 00:00:00 2001 From: SethTheMountain Date: Sun, 4 Oct 2026 15:07:59 -0400 Subject: [PATCH 4/4] Login page: create an identity account from FleetCheck --- frontend/src/auth/tokens.test.ts | 36 ++++++++++++++++++++++++++++++++ frontend/src/auth/tokens.ts | 23 ++++++++++++++++++++ frontend/src/pages/LoginPage.tsx | 26 ++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 frontend/src/auth/tokens.test.ts diff --git a/frontend/src/auth/tokens.test.ts b/frontend/src/auth/tokens.test.ts new file mode 100644 index 0000000..f9ecd52 --- /dev/null +++ b/frontend/src/auth/tokens.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { registerWithIdentity } from './tokens' + +function res(status: number) { + return { ok: status >= 200 && status < 300, status, headers: new Headers(), json: async () => ({}) } +} + +describe('registerWithIdentity', () => { + beforeEach(() => vi.stubGlobal('fetch', vi.fn())) + afterEach(() => vi.unstubAllGlobals()) + + it('posts the trimmed email and password to /auth/register', async () => { + ;(fetch as any).mockResolvedValue(res(201)) + + await registerWithIdentity(' new@example.com ', 'pw12345678') + + const [url, init] = (fetch as any).mock.calls[0] + expect(url).toContain('/auth/register') + expect(JSON.parse(init.body)).toEqual({ email: 'new@example.com', password: 'pw12345678' }) + }) + + it('reports an existing account on 409', async () => { + ;(fetch as any).mockResolvedValue(res(409)) + await expect(registerWithIdentity('a@b.co', 'x')).rejects.toThrow('already exists') + }) + + it('reports a rejected email or password on 400', async () => { + ;(fetch as any).mockResolvedValue(res(400)) + await expect(registerWithIdentity('a@b.co', 'x')).rejects.toThrow('rejected') + }) + + it('reports an unreachable service when fetch throws', async () => { + ;(fetch as any).mockRejectedValue(new Error('network')) + await expect(registerWithIdentity('a@b.co', 'x')).rejects.toThrow('Could not reach') + }) +}) diff --git a/frontend/src/auth/tokens.ts b/frontend/src/auth/tokens.ts index 97afcb6..5b25384 100644 --- a/frontend/src/auth/tokens.ts +++ b/frontend/src/auth/tokens.ts @@ -42,6 +42,29 @@ export async function loginWithIdentity(email: string, password: string): Promis storeTokens(await response.json()) } +// Creates the identity-service account only. The caller signs in afterwards. +export async function registerWithIdentity(email: string, password: string): Promise { + let response: Response + try { + response = await fetch(`${IDENTITY_URL}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim(), password }), + }) + } catch { + throw new Error('Could not reach the sign-in service. It may be waking up, so try again in a minute.') + } + if (response.status === 409) { + throw new Error('An account with that email already exists. Try signing in instead.') + } + if (response.status === 400) { + throw new Error('That email or password was rejected. Use a valid email and a longer password.') + } + if (!response.ok) { + throw new Error('Could not create the account. Please try again.') + } +} + let inFlight: Promise | null = null // Shared refresh: concurrent 401s trigger one request. Resolves true if new tokens were stored. diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 9731918..424dc0b 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,9 +1,11 @@ import { useState, type FormEvent } from 'react' import { Navigate } from 'react-router-dom' import { useAuth } from '../auth/AuthContext' +import { registerWithIdentity } from '../auth/tokens' export function LoginPage() { const { user, login } = useAuth() + const [mode, setMode] = useState<'signin' | 'register'>('signin') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState(null) @@ -18,6 +20,9 @@ export function LoginPage() { setError(null) setSubmitting(true) try { + if (mode === 'register') { + await registerWithIdentity(email, password) + } await login(email, password) } catch (err) { setError(err instanceof Error ? err.message : 'Login failed.') @@ -79,10 +84,29 @@ export function LoginPage() { disabled={submitting} className="w-full rounded bg-graphite px-4 py-2 font-sans text-sm font-semibold uppercase tracking-wide text-paper transition hover:bg-graphite/90 disabled:opacity-50" > - {submitting ? 'Signing in…' : 'Sign in'} + {submitting + ? mode === 'register' ? 'Creating account…' : 'Signing in…' + : mode === 'register' ? 'Create account' : 'Sign in'} + + + {mode === 'register' && ( +

+ New accounts need a FleetCheck administrator to assign a role before they can use the app. +

+ )} + )