diff --git a/backend/pom.xml b/backend/pom.xml index 65d6d02..d770b4c 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -52,6 +52,12 @@ spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-mail + + io.jsonwebtoken diff --git a/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java b/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java new file mode 100644 index 0000000..839b4ee --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java @@ -0,0 +1,30 @@ +package backend.fullstack.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +/** + * Request payload used when accepting an invite and setting initial password. + */ +@Getter +@Setter +@Schema(description = "Accept invite request payload") +public class AcceptInviteRequest { + + @NotBlank(message = "Invite token is required") + @Schema(description = "One-time invite token from email link") + private String token; + + @NotBlank(message = "Password is required") + @Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "Password must contain at least one uppercase letter, one lowercase letter, and one number" + ) + @Schema(description = "New account password", example = "P@ssw0rd1") + private String password; +} diff --git a/backend/src/main/java/backend/fullstack/auth/AuthController.java b/backend/src/main/java/backend/fullstack/auth/AuthController.java index f71f9eb..7346643 100644 --- a/backend/src/main/java/backend/fullstack/auth/AuthController.java +++ b/backend/src/main/java/backend/fullstack/auth/AuthController.java @@ -15,6 +15,7 @@ import backend.fullstack.config.ApiResponse; import backend.fullstack.config.JwtUtil; +import backend.fullstack.auth.invite.UserInviteService; import backend.fullstack.user.User; import jakarta.validation.Valid; @@ -29,15 +30,18 @@ public class AuthController { private final AuthService authService; + private final UserInviteService userInviteService; private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; public AuthController( AuthService authService, + UserInviteService userInviteService, JwtUtil jwtUtil, AuthenticationManager authenticationManager ) { this.authService = authService; + this.userInviteService = userInviteService; this.jwtUtil = jwtUtil; this.authenticationManager = authenticationManager; } @@ -115,4 +119,19 @@ public ResponseEntity> logout() { .header("Set-Cookie", cleanCookie.toString()) .body(ApiResponse.success("Logged out", null)); } + + /** + * Accepts a one-time invite token and sets the initial account password. + */ + @PostMapping("/invite/accept") + @Operation(summary = "Accept invite", description = "Sets initial password using one-time invite token") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Invite accepted"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request data"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Invalid or expired invite token") + }) + public ResponseEntity> acceptInvite(@Valid @RequestBody AcceptInviteRequest request) { + userInviteService.acceptInvite(request.getToken(), request.getPassword()); + return ResponseEntity.ok(ApiResponse.success("Password set successfully", null)); + } } diff --git a/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java b/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java new file mode 100644 index 0000000..eef370a --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java @@ -0,0 +1,31 @@ +package backend.fullstack.auth.invite; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Externalized settings used for invite email generation. + */ +@Configuration +@ConfigurationProperties(prefix = "app.invite") +public class InviteProperties { + + private String frontendBaseUrl = "http://localhost:5173"; + private String fromAddress = "no-reply@iksystem.local"; + + public String getFrontendBaseUrl() { + return frontendBaseUrl; + } + + public void setFrontendBaseUrl(String frontendBaseUrl) { + this.frontendBaseUrl = frontendBaseUrl; + } + + public String getFromAddress() { + return fromAddress; + } + + public void setFromAddress(String fromAddress) { + this.fromAddress = fromAddress; + } +} diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java new file mode 100644 index 0000000..27a3179 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java @@ -0,0 +1,142 @@ +package backend.fullstack.auth.invite; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +/** + * Handles one-time user invites and invite acceptance. + */ +@Service +public class UserInviteService { + + private static final Logger logger = LoggerFactory.getLogger(UserInviteService.class); + private static final int TOKEN_BYTES = 48; + + private final UserInviteTokenRepository userInviteTokenRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JavaMailSender mailSender; + private final InviteProperties inviteProperties; + private final SecureRandom secureRandom = new SecureRandom(); + + public UserInviteService( + UserInviteTokenRepository userInviteTokenRepository, + UserRepository userRepository, + PasswordEncoder passwordEncoder, + JavaMailSender mailSender, + InviteProperties inviteProperties + ) { + this.userInviteTokenRepository = userInviteTokenRepository; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.mailSender = mailSender; + this.inviteProperties = inviteProperties; + } + + @Transactional + public void createAndSendInvite(User user) { + userInviteTokenRepository.deleteByUserIdAndConsumedAtIsNull(user.getId()); + + String rawToken = generateToken(); + String tokenHash = hashToken(rawToken); + + UserInviteToken inviteToken = UserInviteToken.builder() + .userId(user.getId()) + .tokenHash(tokenHash) + .expiresAt(LocalDateTime.now().plusHours(24)) + .build(); + + userInviteTokenRepository.save(inviteToken); + sendInviteEmail(user, rawToken); + } + + @Transactional + public void acceptInvite(String token, String password) { + String tokenHash = hashToken(token); + UserInviteToken inviteToken = userInviteTokenRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new AccessDeniedException("Invalid invite token")); + + if (inviteToken.getConsumedAt() != null) { + throw new AccessDeniedException("Invite token has already been used"); + } + + if (inviteToken.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new AccessDeniedException("Invite token has expired"); + } + + User user = userRepository.findById(inviteToken.getUserId()) + .orElseThrow(() -> new ResourceNotFoundException("Invited user not found")); + + user.setPasswordHash(passwordEncoder.encode(password)); + user.setActive(true); + userRepository.save(user); + + inviteToken.setConsumedAt(LocalDateTime.now()); + userInviteTokenRepository.save(inviteToken); + } + + private void sendInviteEmail(User user, String token) { + String base = trimTrailingSlash(inviteProperties.getFrontendBaseUrl()); + String inviteLink = base + "/set-password?token=" + token; + + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(user.getEmail()); + message.setFrom(inviteProperties.getFromAddress()); + message.setSubject("Set up your account"); + message.setText("Hi " + user.getFirstName() + ",\n\n" + + "Your account has been created. Use this link to set your password:\n" + + inviteLink + "\n\n" + + "This link expires in 24 hours and can only be used once.\n"); + + mailSender.send(message); + logger.info("Invite email sent to userId={} email={}", user.getId(), user.getEmail()); + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return toHex(hash); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + private String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private String trimTrailingSlash(String value) { + if (value == null || value.isBlank()) { + return "http://localhost:5173"; + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } +} diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java new file mode 100644 index 0000000..9f5f897 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java @@ -0,0 +1,54 @@ +package backend.fullstack.auth.invite; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One-time invite token used to activate a user account and set initial password. + */ +@Entity +@Table(name = "user_invite_tokens") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserInviteToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @jakarta.persistence.PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java new file mode 100644 index 0000000..89aeb96 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java @@ -0,0 +1,10 @@ +package backend.fullstack.auth.invite; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserInviteTokenRepository extends JpaRepository { + Optional findByTokenHash(String tokenHash); + void deleteByUserIdAndConsumedAtIsNull(Long userId); +} diff --git a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java index 1c316ce..75d3a0a 100644 --- a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java +++ b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java @@ -57,7 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers("/error").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout", "/api/auth/invite/accept").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/organization") .access(this::canAccessBootstrapSetup) .anyRequest().authenticated() diff --git a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java index 6cbac2c..99ba3dd 100644 --- a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java +++ b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java @@ -1,6 +1,5 @@ package backend.fullstack.permission; -import java.util.List; import java.util.Map; import java.util.Set; @@ -11,6 +10,12 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import backend.fullstack.permission.catalog.DefaultRolePermissionMatrix; +import backend.fullstack.permission.catalog.RolePermissionBinding; +import backend.fullstack.permission.catalog.RolePermissionBindingRepository; +import backend.fullstack.permission.definition.PermissionDefinition; +import backend.fullstack.permission.definition.PermissionDefinitionRepository; +import backend.fullstack.permission.model.Permission; import backend.fullstack.user.role.Role; /** diff --git a/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java deleted file mode 100644 index f33a8c4..0000000 --- a/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java +++ /dev/null @@ -1,80 +0,0 @@ -package backend.fullstack.permission.definition; - -import java.util.Map; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -import backend.fullstack.permission.catalog.DefaultRolePermissionMatrix; -import backend.fullstack.permission.catalog.RolePermissionBinding; -import backend.fullstack.permission.catalog.RolePermissionBindingRepository; -import backend.fullstack.permission.model.Permission; -import backend.fullstack.user.role.Role; - -@Component -public class PermissionBootstrapSeeder implements ApplicationRunner { - - private static final Logger logger = LoggerFactory.getLogger(PermissionBootstrapSeeder.class); - - private final PermissionDefinitionRepository permissionDefinitionRepository; - private final RolePermissionBindingRepository rolePermissionBindingRepository; - - public PermissionBootstrapSeeder( - PermissionDefinitionRepository permissionDefinitionRepository, - RolePermissionBindingRepository rolePermissionBindingRepository - ) { - this.permissionDefinitionRepository = permissionDefinitionRepository; - this.rolePermissionBindingRepository = rolePermissionBindingRepository; - } - - @Override - public void run(ApplicationArguments args) { - try { - seedPermissionDefinitions(); - seedRolePermissions(); - } catch (RuntimeException ex) { - // Startup should not fail solely because permission seed cannot run. - logger.warn("Permission bootstrap seed skipped due to repository/database issue", ex); - } - } - - private void seedPermissionDefinitions() { - for (Permission permission : Permission.values()) { - permissionDefinitionRepository.findByPermissionKey(permission.key()) - .orElseGet(() -> permissionDefinitionRepository.save( - PermissionDefinition.builder() - .permissionKey(permission.key()) - .description(permission.key()) - .build() - )); - } - } - - private void seedRolePermissions() { - Map> defaults = DefaultRolePermissionMatrix.create(); - - for (Map.Entry> entry : defaults.entrySet()) { - Role role = entry.getKey(); - if (rolePermissionBindingRepository.existsByRole(role)) { - continue; - } - - for (Permission permission : entry.getValue()) { - PermissionDefinition definition = permissionDefinitionRepository - .findByPermissionKey(permission.key()) - .orElseThrow(() -> new IllegalStateException("Permission definition missing: " + permission.key())); - - rolePermissionBindingRepository.save( - RolePermissionBinding.builder() - .role(role) - .permission(definition) - .build() - ); - } - } - } -} diff --git a/backend/src/main/java/backend/fullstack/user/UserController.java b/backend/src/main/java/backend/fullstack/user/UserController.java index 272c804..a752c7b 100644 --- a/backend/src/main/java/backend/fullstack/user/UserController.java +++ b/backend/src/main/java/backend/fullstack/user/UserController.java @@ -39,6 +39,7 @@ * - POST /api/users/me/change-password: Change current user password. * - POST /api/users/{id}/deactivate: Deactivate user. * - POST /api/users/{id}/reactivate: Reactivate user. + * - POST /api/users/{id}/resend-invite: Resend invite link email. * * @version 1.0 * @since 03.04.26 @@ -121,4 +122,11 @@ public ApiResponse reactivate(@PathVariable Long id) { userService.reactivate(id); return ApiResponse.success("User reactivated", null); } + + @PostMapping("/{id}/resend-invite") + @Operation(summary = "Resend invite") + public ApiResponse resendInvite(@PathVariable Long id) { + userService.resendInvite(id); + return ApiResponse.success("Invite resent", null); + } } diff --git a/backend/src/main/java/backend/fullstack/user/UserService.java b/backend/src/main/java/backend/fullstack/user/UserService.java index 28f2023..3864bdc 100644 --- a/backend/src/main/java/backend/fullstack/user/UserService.java +++ b/backend/src/main/java/backend/fullstack/user/UserService.java @@ -14,6 +14,7 @@ import backend.fullstack.permission.profile.PermissionProfileRepository; import backend.fullstack.permission.profile.UserProfileAssignment; import backend.fullstack.permission.profile.UserProfileAssignmentRepository; +import backend.fullstack.auth.invite.UserInviteService; import backend.fullstack.user.dto.ChangePasswordRequest; import backend.fullstack.user.dto.CreateUserRequest; import backend.fullstack.user.dto.UpdateUserProfileRequest; @@ -28,6 +29,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; /** * Service for managing users within an organization. @@ -52,6 +54,7 @@ public class UserService { private final UserPermissionOverrideRepository userPermissionOverrideRepository; private final UserLocationScopeAssignmentRepository userLocationScopeAssignmentRepository; private final PasswordEncoder passwordEncoder; + private final UserInviteService userInviteService; /** * Returns all users belonging to the caller's organization. @@ -101,7 +104,6 @@ public UserResponse getMyProfile() { /** * Creates a new user within the caller's organization. - * The password is hashed with BCrypt before persisting. * MANAGER and STAFF roles require a locationId in the request. * * @param request the user creation request @@ -122,13 +124,16 @@ public UserResponse create(CreateUserRequest request) { // 2. Build the entity from the DTO User user = userMapper.toEntity(request); user.setOrganization(accessContext.getCurrentUser().getOrganization()); - user.setPasswordHash(passwordEncoder.encode(request.getPassword())); - user.setActive(true); + // Temporary random password hash; user sets real password via invite link. + user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString())); + user.setActive(false); // 3. Resolve location based on role assignLocationForRole(user, request.getRole(), request.getLocationId(), orgId); - return toResponseWithLocations(userRepository.save(user)); + User saved = userRepository.save(user); + userInviteService.createAndSendInvite(saved); + return toResponseWithLocations(saved); } @@ -290,6 +295,21 @@ public void reactivate(Long id) { userRepository.save(user); } + /** + * Resends invite email for a user in the current organization. + * Only ADMIN can perform this operation. + * + * @param id the target user id + */ + @Transactional + public void resendInvite(Long id) { + accessContext.assertHasRole(Role.ADMIN); + + User user = findInCurrentOrg(id); + authorizationService.assertCanManageUser(user); + userInviteService.createAndSendInvite(user); + } + @Transactional public void assignProfiles( Long id, diff --git a/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java b/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java index 0aaaf75..a3cf54f 100644 --- a/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java +++ b/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java @@ -5,7 +5,6 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; @@ -39,13 +38,4 @@ public class CreateUserRequest { @Schema(description = "ID of the user's home location for STAFF/MANAGER", example = "1") private Long locationId; - - @NotBlank(message = "Password is required") - @Size(min = 8, message = "Password must be at least 8 characters") - @Pattern( - regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", - message = "Password must contain at least one uppercase letter, one lowercase letter, and one number" - ) - @Schema(description = "User's password", example = "P@ssw0rd") - private String password; } diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index d041a2b..abd9463 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -1,2 +1,13 @@ # Flyway in dev: run normal schema migrations + dev-only seed migrations spring.flyway.locations=classpath:db/migration,classpath:db/migration-dev + +# Invite sender for local/dev demo +app.invite.from-address=noreply.iksys@gmail.com + +# Gmail SMTP for local/dev demo +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=noreply.iksys@gmail.com +spring.mail.password=sxxiiqtdxgkulkqy +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index fa0afe9..81bd815 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -23,6 +23,18 @@ app.jwt.expiration-ms=28800000 app.jwt.cookie-name=jwt app.jwt.cookie-secure=true +# Invite onboarding settings +app.invite.frontend-base-url=http://localhost:5173 +app.invite.from-address=no-reply@iksystem.local + +# Mail configuration (set in environment or override per profile) +# spring.mail.host=smtp.example.com +# spring.mail.port=587 +# spring.mail.username=your-username +# spring.mail.password=your-password +# spring.mail.properties.mail.smtp.auth=true +# spring.mail.properties.mail.smtp.starttls.enable=true + # Flyway migrations spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration diff --git a/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql b/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql new file mode 100644 index 0000000..00d7b11 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS user_invite_tokens ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX uk_user_invite_tokens_token_hash ON user_invite_tokens(token_hash); +CREATE INDEX idx_user_invite_tokens_user_id ON user_invite_tokens(user_id); diff --git a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java index 5f19620..a7305fc 100644 --- a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java +++ b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java @@ -17,8 +17,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import backend.fullstack.config.GlobalExceptionHandler; import backend.fullstack.config.JwtProperties; import backend.fullstack.config.JwtUtil; +import backend.fullstack.auth.invite.UserInviteService; import backend.fullstack.organization.Organization; import backend.fullstack.user.User; import backend.fullstack.user.role.Role; @@ -32,13 +34,16 @@ class AuthControllerTest { void setUp() { TestAuthService authService = new TestAuthService(); TestJwtUtil jwtUtil = new TestJwtUtil(); + UserInviteService userInviteService = new TestUserInviteService(); AuthenticationManager authenticationManager = authentication -> { User principal = buildUser(42L, "admin@everest.no", 10L); return new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); }; - AuthController controller = new AuthController(authService, jwtUtil, authenticationManager); - mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + AuthController controller = new AuthController(authService, userInviteService, jwtUtil, authenticationManager); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); objectMapper = new ObjectMapper(); } @@ -88,6 +93,19 @@ void logoutClearsJwtCookie() throws Exception { .andExpect(jsonPath("$.message").value("Logged out")); } + @Test + void acceptInviteReturnsForbiddenForInvalidToken() throws Exception { + AcceptInviteRequest request = new AcceptInviteRequest(); + request.setToken("invalid-token"); + request.setPassword("Password1"); + + mockMvc.perform(post("/api/auth/invite/accept") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED")); + } + private static User buildUser(Long userId, String email, Long orgId) { Organization organization = Organization.builder() .id(orgId) @@ -160,4 +178,18 @@ public ResponseCookie getCleanJwtCookie() { .build(); } } + + private static final class TestUserInviteService extends UserInviteService { + + private TestUserInviteService() { + super(null, null, null, null, null); + } + + @Override + public void acceptInvite(String token, String password) { + if ("invalid-token".equals(token)) { + throw new org.springframework.security.access.AccessDeniedException("Invalid invite token"); + } + } + } } diff --git a/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java new file mode 100644 index 0000000..3c1face --- /dev/null +++ b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java @@ -0,0 +1,140 @@ +package backend.fullstack.auth.invite; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class UserInviteServiceTest { + + @Mock + private UserInviteTokenRepository userInviteTokenRepository; + @Mock + private UserRepository userRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JavaMailSender mailSender; + + private UserInviteService userInviteService; + + @BeforeEach + void setUp() { + InviteProperties inviteProperties = new InviteProperties(); + inviteProperties.setFrontendBaseUrl("http://localhost:5173"); + inviteProperties.setFromAddress("no-reply@test.local"); + + userInviteService = new UserInviteService( + userInviteTokenRepository, + userRepository, + passwordEncoder, + mailSender, + inviteProperties + ); + } + + @Test + void acceptInviteRejectsInvalidToken() { + when(userInviteTokenRepository.findByTokenHash(hash("invalid"))).thenReturn(Optional.empty()); + + assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("invalid", "Password1")); + verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void acceptInviteRejectsExpiredToken() { + UserInviteToken token = UserInviteToken.builder() + .id(1L) + .userId(7L) + .tokenHash(hash("expired-token")) + .expiresAt(LocalDateTime.now().minusMinutes(1)) + .consumedAt(null) + .build(); + + when(userInviteTokenRepository.findByTokenHash(hash("expired-token"))).thenReturn(Optional.of(token)); + + assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("expired-token", "Password1")); + verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void acceptInviteRejectsReusedToken() { + UserInviteToken token = UserInviteToken.builder() + .id(1L) + .userId(7L) + .tokenHash(hash("used-token")) + .expiresAt(LocalDateTime.now().plusHours(1)) + .consumedAt(LocalDateTime.now().minusMinutes(5)) + .build(); + + when(userInviteTokenRepository.findByTokenHash(hash("used-token"))).thenReturn(Optional.of(token)); + + assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("used-token", "Password1")); + verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void acceptInviteConsumesTokenAndActivatesUser() { + UserInviteToken token = UserInviteToken.builder() + .id(1L) + .userId(7L) + .tokenHash(hash("valid-token")) + .expiresAt(LocalDateTime.now().plusHours(1)) + .consumedAt(null) + .build(); + + User user = User.builder() + .id(7L) + .email("user@everest.no") + .firstName("User") + .lastName("Test") + .passwordHash("old") + .role(Role.STAFF) + .organization(Organization.builder().id(1L).name("Everest").organizationNumber("123456789").build()) + .isActive(false) + .build(); + + when(userInviteTokenRepository.findByTokenHash(hash("valid-token"))).thenReturn(Optional.of(token)); + when(userRepository.findById(7L)).thenReturn(Optional.of(user)); + when(passwordEncoder.encode("Password1")).thenReturn("encoded"); + + userInviteService.acceptInvite("valid-token", "Password1"); + + verify(userRepository).save(user); + verify(userInviteTokenRepository).save(token); + } + + private static String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } +} diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java index ff07476..7a62d0d 100644 --- a/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java +++ b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java @@ -1,15 +1,14 @@ package backend.fullstack.organization; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import static org.springframework.http.MediaType.APPLICATION_JSON; +import org.springframework.test.web.servlet.MockMvc; 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; @@ -125,7 +124,7 @@ private static final class TestOrganizationService extends OrganizationService { private Long lastId; private TestOrganizationService() { - super(null, null, null, null, null); + super(null, null, null, null); } @Override diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java index 20aa98b..a7f36aa 100644 --- a/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java +++ b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java @@ -1,18 +1,16 @@ package backend.fullstack.organization; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; import org.mockito.Mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.access.AccessDeniedException; @@ -23,7 +21,6 @@ import backend.fullstack.permission.core.AuthorizationService; import backend.fullstack.permission.model.Permission; import backend.fullstack.user.AccessContextService; -import backend.fullstack.user.UserRepository; @ExtendWith(MockitoExtension.class) class OrganizationServiceTest { @@ -31,8 +28,6 @@ class OrganizationServiceTest { @Mock private OrganizationRepository organizationRepository; @Mock - private UserRepository userRepository; - @Mock private AccessContextService accessContext; @Mock private AuthorizationService authorizationService; @@ -45,7 +40,6 @@ class OrganizationServiceTest { void setUp() { organizationService = new OrganizationService( organizationRepository, - userRepository, accessContext, authorizationService, organizationMapper @@ -58,24 +52,11 @@ void createRejectsDuplicateOrganizationNumber() { request.setName("Everest"); request.setOrganizationNumber("937219997"); - when(userRepository.count()).thenReturn(0L); when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(true); assertThrows(OrganizationConflictException.class, () -> organizationService.create(request)); } - @Test - void createRejectsOrganizationCreationAfterBootstrap() { - OrganizationRequest request = new OrganizationRequest(); - request.setName("Everest"); - request.setOrganizationNumber("937219997"); - - when(userRepository.count()).thenReturn(1L); - - assertThrows(AccessDeniedException.class, () -> organizationService.create(request)); - verify(organizationRepository, never()).save(any(Organization.class)); - } - @Test void createSavesOrganizationAndMapsResponse() { OrganizationRequest request = new OrganizationRequest(); @@ -98,7 +79,6 @@ void createSavesOrganizationAndMapsResponse() { .locationCount(0) .build(); - when(userRepository.count()).thenReturn(0L); when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(false); when(organizationMapper.toEntity(request)).thenReturn(mapped); when(organizationRepository.save(any(Organization.class))).thenReturn(saved); @@ -135,8 +115,7 @@ void updateCurrentOrganizationSavesChangesBeforeMappingResponse() { when(accessContext.getCurrentOrganizationId()).thenReturn(100L); when(organizationRepository.findById(100L)).thenReturn(Optional.of(existing)); when(organizationRepository.existsByOrganizationNumberAndIdNot("987654321", 100L)).thenReturn(false); - when(organizationRepository.save(existing)).thenReturn(saved); - when(organizationMapper.toResponse(saved)).thenReturn(response); + when(organizationMapper.toResponse(existing)).thenReturn(response); OrganizationResponse result = organizationService.updateCurrentOrganization(request); @@ -144,7 +123,6 @@ void updateCurrentOrganizationSavesChangesBeforeMappingResponse() { assertEquals("Updated Everest", existing.getName()); assertEquals("987654321", existing.getOrganizationNumber()); verify(authorizationService).assertPermission(Permission.ORGANIZATION_SETTINGS_UPDATE); - verify(organizationRepository).save(existing); } @Test diff --git a/backend/src/test/java/backend/fullstack/user/UserControllerTest.java b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java index f965897..96f38bd 100644 --- a/backend/src/test/java/backend/fullstack/user/UserControllerTest.java +++ b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java @@ -171,6 +171,15 @@ void reactivateDelegatesToService() throws Exception { assert userService.reactivatedId.equals(3L); } + @Test + void resendInviteDelegatesToService() throws Exception { + mockMvc.perform(post("/api/users/3/resend-invite")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Invite resent")); + + assert userService.resendInviteId.equals(3L); + } + @Test void createReturnsValidationErrorForMissingFields() throws Exception { mockMvc.perform(post("/api/users") @@ -181,8 +190,7 @@ void createReturnsValidationErrorForMissingFields() throws Exception { .andExpect(jsonPath("$.fieldErrors.firstName").value("First name is required")) .andExpect(jsonPath("$.fieldErrors.lastName").value("Last name is required")) .andExpect(jsonPath("$.fieldErrors.email").value("Email is required")) - .andExpect(jsonPath("$.fieldErrors.role").value("Role is required")) - .andExpect(jsonPath("$.fieldErrors.password").value("Password is required")); + .andExpect(jsonPath("$.fieldErrors.role").value("Role is required")); } private static CreateUserRequest createRequest() { @@ -192,7 +200,6 @@ private static CreateUserRequest createRequest() { request.setEmail("staff@everest.no"); request.setRole(Role.STAFF); request.setLocationId(1L); - request.setPassword("Password1"); return request; } @@ -235,9 +242,10 @@ private static final class TestUserService extends UserService { private Long lastId; private Long deactivatedId; private Long reactivatedId; + private Long resendInviteId; private TestUserService() { - super(null, null, null, null, null, null, null, null, null, null); + super(null, null, null, null, null, null, null, null, null, null, null); } @Override @@ -295,5 +303,10 @@ public void deactivate(Long id) { public void reactivate(Long id) { reactivatedId = id; } + + @Override + public void resendInvite(Long id) { + resendInviteId = id; + } } } diff --git a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java index b6d34f5..53511a1 100644 --- a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java +++ b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java @@ -40,6 +40,7 @@ import backend.fullstack.permission.profile.PermissionProfileRepository; import backend.fullstack.permission.profile.UserProfileAssignment; import backend.fullstack.permission.profile.UserProfileAssignmentRepository; +import backend.fullstack.auth.invite.UserInviteService; import backend.fullstack.user.dto.UserMapper; import backend.fullstack.user.role.Role; @@ -62,6 +63,8 @@ class UserServicePermissionFlowTest { private UserLocationScopeAssignmentRepository userLocationScopeAssignmentRepository; @Mock private PasswordEncoder passwordEncoder; + @Mock + private UserInviteService userInviteService; private AccessContextService accessContext; private AuthorizationService authorizationService; @@ -81,7 +84,8 @@ void setUp() { userProfileAssignmentRepository, userPermissionOverrideRepository, userLocationScopeAssignmentRepository, - passwordEncoder + passwordEncoder, + userInviteService ); setAdminPrincipal(); @@ -218,6 +222,24 @@ void assignProfilesRequiresAdminRole() { () -> userService.assignProfiles(50L, List.of(1L), null, null, null, true)); } + @Test + void resendInviteRequiresAdminRole() { + setPrincipal(Role.MANAGER); + + assertThrows(AccessDeniedException.class, () -> userService.resendInvite(50L)); + verify(userInviteService, never()).createAndSendInvite(any()); + } + + @Test + void resendInviteAsAdminDelegatesToInviteService() { + User target = targetUser(); + when(userRepository.findById(50L)).thenReturn(Optional.of(target)); + + userService.resendInvite(50L); + + verify(userInviteService).createAndSendInvite(target); + } + private static User targetUser() { Organization organization = Organization.builder() .id(100L) diff --git a/mail.md b/mail.md new file mode 100644 index 0000000..7d36afb --- /dev/null +++ b/mail.md @@ -0,0 +1,45 @@ +# Mail Production Checklist + +Use this before going live with invite and notification email flows. + +## Security +- Never hardcode SMTP passwords or API keys in source code. +- Store secrets in environment variables or a secret manager. +- Rotate credentials regularly (and immediately after demos if shared). +- Use a dedicated sender account for system mail (not a personal mailbox). + +## Sender Domain and Deliverability +- Send from a domain you control (for example: noreply@yourdomain.com). +- Configure and verify SPF, DKIM, and DMARC for the sender domain. +- Keep a stable From address and friendly sender name. +- Warm up new sending domains/accounts gradually. + +## Provider and Setup +- Prefer a transactional provider for production (SendGrid, Postmark, SES, Mailgun, etc.). +- Keep separate credentials for dev, staging, and production. +- Keep sandbox/test mode for non-production environments. +- Set sane SMTP/API timeouts. + +## App Behavior +- Treat email send as a recoverable operation (retry strategy/backoff). +- Consider async sending (queue/outbox) to avoid blocking API requests. +- Log message IDs and delivery attempts (without logging secrets). +- Decide failure behavior clearly: rollback vs continue with warning. + +## Invite Flow Specific +- One-time token only, short expiry (24h is fine). +- Invalidate old pending invites on resend. +- Add resend cooldown/rate limit to prevent abuse. +- Add audit events: invite created, resent, accepted, expired. + +## Observability and Ops +- Add alerts for high mail failure rate. +- Track bounce, block, and complaint metrics. +- Create a simple runbook for SMTP/provider outages. +- Test full invite flow regularly in staging. + +## Compliance and Content +- Keep email templates clear and minimal. +- Avoid sensitive data in email body. +- Include support contact information. +- Ensure legal/compliance requirements are met for your region.