Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Spring Boot Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!-- JWT Token -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 19 additions & 0 deletions backend/src/main/java/backend/fullstack/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -115,4 +119,19 @@ public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> acceptInvite(@Valid @RequestBody AcceptInviteRequest request) {
userInviteService.acceptInvite(request.getToken(), request.getPassword());
return ResponseEntity.ok(ApiResponse.success("Password set successfully", null));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<UserInviteToken, Long> {
Optional<UserInviteToken> findByTokenHash(String tokenHash);
void deleteByUserIdAndConsumedAtIsNull(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package backend.fullstack.permission;

import java.util.List;
import java.util.Map;
import java.util.Set;

Expand All @@ -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;

/**
Expand Down
Loading
Loading