Skip to content

Commit b2ef36a

Browse files
Merge pull request #31 from Stcwal/backend/mail-system
Add user invite flow with email and tokens
2 parents 577cfe3 + 93ea610 commit b2ef36a

23 files changed

Lines changed: 634 additions & 136 deletions

backend/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
<artifactId>spring-boot-starter-validation</artifactId>
5353
</dependency>
5454

55+
<!-- Spring Boot Mail -->
56+
<dependency>
57+
<groupId>org.springframework.boot</groupId>
58+
<artifactId>spring-boot-starter-mail</artifactId>
59+
</dependency>
60+
5561
<!-- JWT Token -->
5662
<dependency>
5763
<groupId>io.jsonwebtoken</groupId>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package backend.fullstack.auth;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
6+
import jakarta.validation.constraints.Size;
7+
import lombok.Getter;
8+
import lombok.Setter;
9+
10+
/**
11+
* Request payload used when accepting an invite and setting initial password.
12+
*/
13+
@Getter
14+
@Setter
15+
@Schema(description = "Accept invite request payload")
16+
public class AcceptInviteRequest {
17+
18+
@NotBlank(message = "Invite token is required")
19+
@Schema(description = "One-time invite token from email link")
20+
private String token;
21+
22+
@NotBlank(message = "Password is required")
23+
@Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters")
24+
@Pattern(
25+
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
26+
message = "Password must contain at least one uppercase letter, one lowercase letter, and one number"
27+
)
28+
@Schema(description = "New account password", example = "P@ssw0rd1")
29+
private String password;
30+
}

backend/src/main/java/backend/fullstack/auth/AuthController.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import backend.fullstack.config.ApiResponse;
1717
import backend.fullstack.config.JwtUtil;
18+
import backend.fullstack.auth.invite.UserInviteService;
1819
import backend.fullstack.user.User;
1920
import jakarta.validation.Valid;
2021

@@ -29,15 +30,18 @@
2930
public class AuthController {
3031

3132
private final AuthService authService;
33+
private final UserInviteService userInviteService;
3234
private final JwtUtil jwtUtil;
3335
private final AuthenticationManager authenticationManager;
3436

3537
public AuthController(
3638
AuthService authService,
39+
UserInviteService userInviteService,
3740
JwtUtil jwtUtil,
3841
AuthenticationManager authenticationManager
3942
) {
4043
this.authService = authService;
44+
this.userInviteService = userInviteService;
4145
this.jwtUtil = jwtUtil;
4246
this.authenticationManager = authenticationManager;
4347
}
@@ -115,4 +119,19 @@ public ResponseEntity<ApiResponse<Void>> logout() {
115119
.header("Set-Cookie", cleanCookie.toString())
116120
.body(ApiResponse.success("Logged out", null));
117121
}
122+
123+
/**
124+
* Accepts a one-time invite token and sets the initial account password.
125+
*/
126+
@PostMapping("/invite/accept")
127+
@Operation(summary = "Accept invite", description = "Sets initial password using one-time invite token")
128+
@ApiResponses(value = {
129+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Invite accepted"),
130+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request data"),
131+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Invalid or expired invite token")
132+
})
133+
public ResponseEntity<ApiResponse<Void>> acceptInvite(@Valid @RequestBody AcceptInviteRequest request) {
134+
userInviteService.acceptInvite(request.getToken(), request.getPassword());
135+
return ResponseEntity.ok(ApiResponse.success("Password set successfully", null));
136+
}
118137
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package backend.fullstack.auth.invite;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
/**
7+
* Externalized settings used for invite email generation.
8+
*/
9+
@Configuration
10+
@ConfigurationProperties(prefix = "app.invite")
11+
public class InviteProperties {
12+
13+
private String frontendBaseUrl = "http://localhost:5173";
14+
private String fromAddress = "no-reply@iksystem.local";
15+
16+
public String getFrontendBaseUrl() {
17+
return frontendBaseUrl;
18+
}
19+
20+
public void setFrontendBaseUrl(String frontendBaseUrl) {
21+
this.frontendBaseUrl = frontendBaseUrl;
22+
}
23+
24+
public String getFromAddress() {
25+
return fromAddress;
26+
}
27+
28+
public void setFromAddress(String fromAddress) {
29+
this.fromAddress = fromAddress;
30+
}
31+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package backend.fullstack.auth.invite;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.security.MessageDigest;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.security.SecureRandom;
7+
import java.time.LocalDateTime;
8+
import java.util.Base64;
9+
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
import org.springframework.mail.SimpleMailMessage;
13+
import org.springframework.mail.javamail.JavaMailSender;
14+
import org.springframework.security.access.AccessDeniedException;
15+
import org.springframework.security.crypto.password.PasswordEncoder;
16+
import org.springframework.stereotype.Service;
17+
import org.springframework.transaction.annotation.Transactional;
18+
19+
import backend.fullstack.exceptions.ResourceNotFoundException;
20+
import backend.fullstack.user.User;
21+
import backend.fullstack.user.UserRepository;
22+
23+
/**
24+
* Handles one-time user invites and invite acceptance.
25+
*/
26+
@Service
27+
public class UserInviteService {
28+
29+
private static final Logger logger = LoggerFactory.getLogger(UserInviteService.class);
30+
private static final int TOKEN_BYTES = 48;
31+
32+
private final UserInviteTokenRepository userInviteTokenRepository;
33+
private final UserRepository userRepository;
34+
private final PasswordEncoder passwordEncoder;
35+
private final JavaMailSender mailSender;
36+
private final InviteProperties inviteProperties;
37+
private final SecureRandom secureRandom = new SecureRandom();
38+
39+
public UserInviteService(
40+
UserInviteTokenRepository userInviteTokenRepository,
41+
UserRepository userRepository,
42+
PasswordEncoder passwordEncoder,
43+
JavaMailSender mailSender,
44+
InviteProperties inviteProperties
45+
) {
46+
this.userInviteTokenRepository = userInviteTokenRepository;
47+
this.userRepository = userRepository;
48+
this.passwordEncoder = passwordEncoder;
49+
this.mailSender = mailSender;
50+
this.inviteProperties = inviteProperties;
51+
}
52+
53+
@Transactional
54+
public void createAndSendInvite(User user) {
55+
userInviteTokenRepository.deleteByUserIdAndConsumedAtIsNull(user.getId());
56+
57+
String rawToken = generateToken();
58+
String tokenHash = hashToken(rawToken);
59+
60+
UserInviteToken inviteToken = UserInviteToken.builder()
61+
.userId(user.getId())
62+
.tokenHash(tokenHash)
63+
.expiresAt(LocalDateTime.now().plusHours(24))
64+
.build();
65+
66+
userInviteTokenRepository.save(inviteToken);
67+
sendInviteEmail(user, rawToken);
68+
}
69+
70+
@Transactional
71+
public void acceptInvite(String token, String password) {
72+
String tokenHash = hashToken(token);
73+
UserInviteToken inviteToken = userInviteTokenRepository.findByTokenHash(tokenHash)
74+
.orElseThrow(() -> new AccessDeniedException("Invalid invite token"));
75+
76+
if (inviteToken.getConsumedAt() != null) {
77+
throw new AccessDeniedException("Invite token has already been used");
78+
}
79+
80+
if (inviteToken.getExpiresAt().isBefore(LocalDateTime.now())) {
81+
throw new AccessDeniedException("Invite token has expired");
82+
}
83+
84+
User user = userRepository.findById(inviteToken.getUserId())
85+
.orElseThrow(() -> new ResourceNotFoundException("Invited user not found"));
86+
87+
user.setPasswordHash(passwordEncoder.encode(password));
88+
user.setActive(true);
89+
userRepository.save(user);
90+
91+
inviteToken.setConsumedAt(LocalDateTime.now());
92+
userInviteTokenRepository.save(inviteToken);
93+
}
94+
95+
private void sendInviteEmail(User user, String token) {
96+
String base = trimTrailingSlash(inviteProperties.getFrontendBaseUrl());
97+
String inviteLink = base + "/set-password?token=" + token;
98+
99+
SimpleMailMessage message = new SimpleMailMessage();
100+
message.setTo(user.getEmail());
101+
message.setFrom(inviteProperties.getFromAddress());
102+
message.setSubject("Set up your account");
103+
message.setText("Hi " + user.getFirstName() + ",\n\n"
104+
+ "Your account has been created. Use this link to set your password:\n"
105+
+ inviteLink + "\n\n"
106+
+ "This link expires in 24 hours and can only be used once.\n");
107+
108+
mailSender.send(message);
109+
logger.info("Invite email sent to userId={} email={}", user.getId(), user.getEmail());
110+
}
111+
112+
private String generateToken() {
113+
byte[] bytes = new byte[TOKEN_BYTES];
114+
secureRandom.nextBytes(bytes);
115+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
116+
}
117+
118+
private String hashToken(String token) {
119+
try {
120+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
121+
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
122+
return toHex(hash);
123+
} catch (NoSuchAlgorithmException ex) {
124+
throw new IllegalStateException("SHA-256 not available", ex);
125+
}
126+
}
127+
128+
private String toHex(byte[] bytes) {
129+
StringBuilder sb = new StringBuilder(bytes.length * 2);
130+
for (byte b : bytes) {
131+
sb.append(String.format("%02x", b));
132+
}
133+
return sb.toString();
134+
}
135+
136+
private String trimTrailingSlash(String value) {
137+
if (value == null || value.isBlank()) {
138+
return "http://localhost:5173";
139+
}
140+
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
141+
}
142+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package backend.fullstack.auth.invite;
2+
3+
import java.time.LocalDateTime;
4+
5+
import jakarta.persistence.Column;
6+
import jakarta.persistence.Entity;
7+
import jakarta.persistence.GeneratedValue;
8+
import jakarta.persistence.GenerationType;
9+
import jakarta.persistence.Id;
10+
import jakarta.persistence.Table;
11+
import lombok.AllArgsConstructor;
12+
import lombok.Builder;
13+
import lombok.Getter;
14+
import lombok.NoArgsConstructor;
15+
import lombok.Setter;
16+
17+
/**
18+
* One-time invite token used to activate a user account and set initial password.
19+
*/
20+
@Entity
21+
@Table(name = "user_invite_tokens")
22+
@Getter
23+
@Setter
24+
@NoArgsConstructor
25+
@AllArgsConstructor
26+
@Builder
27+
public class UserInviteToken {
28+
29+
@Id
30+
@GeneratedValue(strategy = GenerationType.IDENTITY)
31+
private Long id;
32+
33+
@Column(name = "user_id", nullable = false)
34+
private Long userId;
35+
36+
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
37+
private String tokenHash;
38+
39+
@Column(name = "expires_at", nullable = false)
40+
private LocalDateTime expiresAt;
41+
42+
@Column(name = "consumed_at")
43+
private LocalDateTime consumedAt;
44+
45+
@Column(name = "created_at", nullable = false, updatable = false)
46+
private LocalDateTime createdAt;
47+
48+
@jakarta.persistence.PrePersist
49+
protected void onCreate() {
50+
if (createdAt == null) {
51+
createdAt = LocalDateTime.now();
52+
}
53+
}
54+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package backend.fullstack.auth.invite;
2+
3+
import java.util.Optional;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
public interface UserInviteTokenRepository extends JpaRepository<UserInviteToken, Long> {
8+
Optional<UserInviteToken> findByTokenHash(String tokenHash);
9+
void deleteByUserIdAndConsumedAtIsNull(Long userId);
10+
}

backend/src/main/java/backend/fullstack/config/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
5757
.authorizeHttpRequests(auth -> auth
5858
.requestMatchers("/error").permitAll()
5959
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
60-
.requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout").permitAll()
60+
.requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout", "/api/auth/invite/accept").permitAll()
6161
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/organization")
6262
.access(this::canAccessBootstrapSetup)
6363
.anyRequest().authenticated()

backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package backend.fullstack.permission;
22

3-
import java.util.List;
43
import java.util.Map;
54
import java.util.Set;
65

@@ -11,6 +10,12 @@
1110
import org.springframework.stereotype.Component;
1211
import org.springframework.transaction.annotation.Transactional;
1312

13+
import backend.fullstack.permission.catalog.DefaultRolePermissionMatrix;
14+
import backend.fullstack.permission.catalog.RolePermissionBinding;
15+
import backend.fullstack.permission.catalog.RolePermissionBindingRepository;
16+
import backend.fullstack.permission.definition.PermissionDefinition;
17+
import backend.fullstack.permission.definition.PermissionDefinitionRepository;
18+
import backend.fullstack.permission.model.Permission;
1419
import backend.fullstack.user.role.Role;
1520

1621
/**

0 commit comments

Comments
 (0)