Skip to content

Commit 8fc188c

Browse files
authored
Merge pull request #217 from Edamijueda/issue-#158
Add Password Policy Functionality
2 parents 32e79c3 + 0f4ca43 commit 8fc188c

14 files changed

Lines changed: 11128 additions & 103 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ dependencies {
6565
// Other dependencies (moved to test scope for library)
6666
implementation 'org.passay:passay:1.6.6'
6767
implementation 'com.google.guava:guava:33.5.0-jre'
68+
implementation 'org.apache.commons:commons-text:1.13.1'
6869
compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1'
6970
compileOnly 'org.springframework.retry:spring-retry'
7071

src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.digitalsanctuary.spring.user.api;
22

3+
import java.util.List;
34
import java.util.Locale;
45
import jakarta.validation.Valid;
56
import org.springframework.beans.factory.annotation.Value;
@@ -23,6 +24,7 @@
2324
import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
2425
import com.digitalsanctuary.spring.user.persistence.model.User;
2526
import com.digitalsanctuary.spring.user.service.DSUserDetails;
27+
import com.digitalsanctuary.spring.user.service.PasswordPolicyService;
2628
import com.digitalsanctuary.spring.user.service.UserEmailService;
2729
import com.digitalsanctuary.spring.user.service.UserService;
2830
import com.digitalsanctuary.spring.user.util.JSONResponse;
@@ -33,7 +35,8 @@
3335
import lombok.extern.slf4j.Slf4j;
3436

3537
/**
36-
* REST controller for managing user-related operations. This class handles user registration, account deletion, and other user-related endpoints.
38+
* REST controller for managing user-related operations. This class handles user
39+
* registration, account deletion, and other user-related endpoints.
3740
*/
3841
@Slf4j
3942
@RequiredArgsConstructor
@@ -45,6 +48,7 @@ public class UserAPI {
4548
private final UserEmailService userEmailService;
4649
private final MessageSource messages;
4750
private final ApplicationEventPublisher eventPublisher;
51+
private final PasswordPolicyService passwordPolicyService;
4852

4953
@Value("${user.security.registrationPendingURI}")
5054
private String registrationPendingURI;
@@ -55,19 +59,30 @@ public class UserAPI {
5559
@Value("${user.security.forgotPasswordPendingURI}")
5660
private String forgotPasswordPendingURI;
5761

58-
59-
6062
/**
6163
* Registers a new user account.
6264
*
6365
* @param userDto the user data transfer object containing user details
6466
* @param request the HTTP servlet request
65-
* @return a ResponseEntity containing a JSONResponse with the registration result
67+
* @return a ResponseEntity containing a JSONResponse with the registration
68+
* result
6669
*/
6770
@PostMapping("/registration")
68-
public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody UserDto userDto, HttpServletRequest request) {
71+
public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody UserDto userDto,
72+
HttpServletRequest request) {
6973
try {
7074
validateUserDto(userDto);
75+
76+
// Password Policy Enforcement
77+
List<String> errors = passwordPolicyService.validate(null, userDto.getPassword(),
78+
userDto.getEmail(), request.getLocale());
79+
80+
// Check if any password validation errors exist
81+
if (!errors.isEmpty()) {
82+
log.warn("Password validation failed: {}", errors);
83+
return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST);
84+
}
85+
7186
User registeredUser = userService.registerNewUserAccount(userDto);
7287
publishRegistrationEvent(registeredUser, request);
7388
logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request);
@@ -87,14 +102,17 @@ public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody User
87102
}
88103

89104
/**
90-
* Resends the registration token. This is used when the user did not receive the initial registration email.
105+
* Resends the registration token. This is used when the user did not receive
106+
* the initial registration email.
91107
*
92108
* @param userDto the user data transfer object containing user details
93109
* @param request the HTTP servlet request
94-
* @return a ResponseEntity containing a JSONResponse with the registration result
110+
* @return a ResponseEntity containing a JSONResponse with the registration
111+
* result
95112
*/
96113
@PostMapping("/resendRegistrationToken")
97-
public ResponseEntity<JSONResponse> resendRegistrationToken(@Valid @RequestBody UserDto userDto, HttpServletRequest request) {
114+
public ResponseEntity<JSONResponse> resendRegistrationToken(@Valid @RequestBody UserDto userDto,
115+
HttpServletRequest request) {
98116
User user = userService.findUserByEmail(userDto.getEmail());
99117
if (user != null) {
100118
if (user.isEnabled()) {
@@ -108,16 +126,19 @@ public ResponseEntity<JSONResponse> resendRegistrationToken(@Valid @RequestBody
108126
}
109127

110128
/**
111-
* Updates the user's password. This is used when the user is logged in and wants to change their password.
129+
* Updates the user's password. This is used when the user is logged in and
130+
* wants to change their password.
112131
*
113132
* @param userDetails the authenticated user details
114-
* @param userDto the user data transfer object containing user details
115-
* @param request the HTTP servlet request
116-
* @param locale the locale
117-
* @return a ResponseEntity containing a JSONResponse with the password update result
133+
* @param userDto the user data transfer object containing user details
134+
* @param request the HTTP servlet request
135+
* @param locale the locale
136+
* @return a ResponseEntity containing a JSONResponse with the password update
137+
* result
118138
*/
119139
@PostMapping("/updateUser")
120-
public ResponseEntity<JSONResponse> updateUserAccount(@AuthenticationPrincipal DSUserDetails userDetails, @Valid @RequestBody UserDto userDto,
140+
public ResponseEntity<JSONResponse> updateUserAccount(@AuthenticationPrincipal DSUserDetails userDetails,
141+
@Valid @RequestBody UserDto userDto,
121142
HttpServletRequest request, Locale locale) {
122143
validateAuthenticatedUser(userDetails);
123144
User user = userDetails.getUser();
@@ -131,12 +152,14 @@ public ResponseEntity<JSONResponse> updateUserAccount(@AuthenticationPrincipal D
131152
}
132153

133154
/**
134-
* This is used when the user has forgotten their password and wants to reset their password. This will send an email to the user with a link to
155+
* This is used when the user has forgotten their password and wants to reset
156+
* their password. This will send an email to the user with a link to
135157
* reset their password.
136158
*
137159
* @param passwordResetRequest the password reset request containing the email address
138160
* @param request the HTTP servlet request
139-
* @return a ResponseEntity containing a JSONResponse with the password reset email send result
161+
* @return a ResponseEntity containing a JSONResponse with the password reset
162+
* email send result
140163
*/
141164
@PostMapping("/resetPassword")
142165
public ResponseEntity<JSONResponse> resetPassword(@Valid @RequestBody PasswordResetRequestDto passwordResetRequest, HttpServletRequest request) {
@@ -149,13 +172,16 @@ public ResponseEntity<JSONResponse> resetPassword(@Valid @RequestBody PasswordRe
149172
}
150173

151174
/**
152-
* Updates the user's password. This is used when the user is logged in and wants to change their password.
175+
* Updates the user's password. This is used when the user is logged in and
176+
* wants to change their password.
153177
*
154178
* @param userDetails the authenticated user details
155-
* @param passwordDto the password data transfer object containing the old and new passwords
156-
* @param request the HTTP servlet request
157-
* @param locale the locale
158-
* @return a ResponseEntity containing a JSONResponse with the password update result
179+
* @param passwordDto the password data transfer object containing the old and
180+
* new passwords
181+
* @param request the HTTP servlet request
182+
* @param locale the locale
183+
* @return a ResponseEntity containing a JSONResponse with the password update
184+
* result
159185
*/
160186
@PostMapping("/updatePassword")
161187
public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUserDetails userDetails,
@@ -174,7 +200,8 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs
174200
return buildSuccessResponse(messages.getMessage("message.update-password.success", null, locale), null);
175201
} catch (InvalidOldPasswordException ex) {
176202
logAuditEvent("PasswordUpdate", "Failure", "Invalid old password", user, request);
177-
return buildErrorResponse(messages.getMessage("message.update-password.invalid-old", null, locale), 1, HttpStatus.BAD_REQUEST);
203+
return buildErrorResponse(messages.getMessage("message.update-password.invalid-old", null, locale), 1,
204+
HttpStatus.BAD_REQUEST);
178205
} catch (Exception ex) {
179206
log.error("Unexpected error during password update.", ex);
180207
logAuditEvent("PasswordUpdate", "Failure", ex.getMessage(), user, request);
@@ -183,15 +210,19 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs
183210
}
184211

185212
/**
186-
* Deletes the user's account. This is used when the user wants to delete their account. This will either delete the account or disable it based
187-
* on the configuration of the actuallyDeleteAccount property. After the account is disabled or deleted, the user will be logged out.
213+
* Deletes the user's account. This is used when the user wants to delete their
214+
* account. This will either delete the account or disable it based
215+
* on the configuration of the actuallyDeleteAccount property. After the account
216+
* is disabled or deleted, the user will be logged out.
188217
*
189218
* @param userDetails the authenticated user details
190-
* @param request the HTTP servlet request
191-
* @return a ResponseEntity containing a JSONResponse with the account deletion result
219+
* @param request the HTTP servlet request
220+
* @return a ResponseEntity containing a JSONResponse with the account deletion
221+
* result
192222
*/
193223
@DeleteMapping("/deleteAccount")
194-
public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUserDetails userDetails, HttpServletRequest request) {
224+
public ResponseEntity<JSONResponse> deleteAccount(@AuthenticationPrincipal DSUserDetails userDetails,
225+
HttpServletRequest request) {
195226
validateAuthenticatedUser(userDetails);
196227
User user = userDetails.getUser();
197228
userService.deleteOrDisableUser(user);
@@ -254,7 +285,7 @@ private void logoutUser(HttpServletRequest request) {
254285
/**
255286
* Publishes a registration event.
256287
*
257-
* @param user the registered user
288+
* @param user the registered user
258289
* @param request the HTTP servlet request
259290
*/
260291
private void publishRegistrationEvent(User user, HttpServletRequest request) {
@@ -265,16 +296,17 @@ private void publishRegistrationEvent(User user, HttpServletRequest request) {
265296
/**
266297
* Logs an audit event.
267298
*
268-
* @param action the action performed
269-
* @param status the status of the action
299+
* @param action the action performed
300+
* @param status the status of the action
270301
* @param message the message describing the action
271-
* @param user the user involved in the action
302+
* @param user the user involved in the action
272303
* @param request the HTTP servlet request
273304
*/
274305
private void logAuditEvent(String action, String status, String message, User user, HttpServletRequest request) {
275-
AuditEvent event =
276-
AuditEvent.builder().source(this).user(user).sessionId(request.getSession().getId()).ipAddress(UserUtils.getClientIP(request))
277-
.userAgent(request.getHeader("User-Agent")).action(action).actionStatus(status).message(message).build();
306+
AuditEvent event = AuditEvent.builder().source(this).user(user).sessionId(request.getSession().getId())
307+
.ipAddress(UserUtils.getClientIP(request))
308+
.userAgent(request.getHeader("User-Agent")).action(action).actionStatus(status).message(message)
309+
.build();
278310
eventPublisher.publishEvent(event);
279311
}
280312

@@ -297,7 +329,8 @@ private boolean isNullOrEmpty(String value) {
297329
* @return a ResponseEntity containing a JSONResponse with the error response
298330
*/
299331
private ResponseEntity<JSONResponse> buildErrorResponse(String message, int code, HttpStatus status) {
300-
return ResponseEntity.status(status).body(JSONResponse.builder().success(false).code(code).message(message).build());
332+
return ResponseEntity.status(status)
333+
.body(JSONResponse.builder().success(false).code(code).message(message).build());
301334
}
302335

303336
/**
@@ -308,6 +341,7 @@ private ResponseEntity<JSONResponse> buildErrorResponse(String message, int code
308341
* @return a ResponseEntity containing a JSONResponse with the success response
309342
*/
310343
private ResponseEntity<JSONResponse> buildSuccessResponse(String message, String redirectUrl) {
311-
return ResponseEntity.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build());
344+
return ResponseEntity
345+
.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build());
312346
}
313347
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.digitalsanctuary.spring.user.persistence.model;
2+
3+
import java.time.LocalDateTime;
4+
5+
import jakarta.persistence.Column;
6+
import jakarta.persistence.Entity;
7+
import jakarta.persistence.FetchType;
8+
import jakarta.persistence.GeneratedValue;
9+
import jakarta.persistence.GenerationType;
10+
import jakarta.persistence.Id;
11+
import jakarta.persistence.Index;
12+
import jakarta.persistence.JoinColumn;
13+
import jakarta.persistence.ManyToOne;
14+
import jakarta.persistence.Table;
15+
16+
import lombok.Data;
17+
18+
/**
19+
* The PasswordHistoryEntry Entity.
20+
* Stores password hashes for a user to enforce password history policies.
21+
*/
22+
@Data
23+
@Entity
24+
@Table(name = "password_history_entry", indexes = {
25+
@Index(name = "idx_user_id", columnList = "user_id"),
26+
@Index(name = "idx_entry_date", columnList = "entry_date")
27+
})
28+
public class PasswordHistoryEntry {
29+
30+
/** The id. */
31+
@Id
32+
@GeneratedValue(strategy = GenerationType.IDENTITY)
33+
private Long id;
34+
35+
/** The user associated with this password entry. */
36+
@ManyToOne(fetch = FetchType.EAGER)
37+
@JoinColumn(name = "user_id", nullable = false)
38+
private User user;
39+
40+
/** The hashed password. */
41+
@Column(length = 255, nullable = false)
42+
private String passwordHash;
43+
44+
/** The timestamp when the password was stored. */
45+
@Column(name = "entry_date", nullable = false)
46+
private LocalDateTime entryDate;
47+
48+
/**
49+
* Instantiates a new password history entry.
50+
*/
51+
public PasswordHistoryEntry() {
52+
super();
53+
}
54+
55+
/**
56+
* Instantiates a new password history entry with all fields.
57+
*
58+
* @param user the user
59+
* @param passwordHash the password hash
60+
* @param entryDate the entry date
61+
*/
62+
public PasswordHistoryEntry(final User user, final String passwordHash, final LocalDateTime entryDate) {
63+
this.user = user;
64+
this.passwordHash = passwordHash;
65+
this.entryDate = entryDate;
66+
}
67+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.digitalsanctuary.spring.user.persistence.repository;
2+
3+
import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry;
4+
import com.digitalsanctuary.spring.user.persistence.model.User;
5+
import org.springframework.data.domain.Pageable;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
9+
import java.util.List;
10+
11+
/**
12+
* The Interface PasswordHistoryRepository.
13+
* Handles CRUD operations for Password History entries.
14+
*/
15+
public interface PasswordHistoryRepository extends JpaRepository<PasswordHistoryEntry, Long> {
16+
17+
/**
18+
* Fetch the most recent password hashes for a user, limited by pageable.
19+
* Used for checking against password reuse.
20+
*
21+
* @param user the user
22+
* @param pageable the pageable object defining limit
23+
* @return list of password hashes
24+
*/
25+
@Query("SELECT p.passwordHash FROM PasswordHistoryEntry p WHERE p.user = :user ORDER BY p.entryDate DESC")
26+
List<String> findRecentPasswordHashes(User user, Pageable pageable);
27+
28+
/**
29+
* Get all history entries for a user ordered by newest first.
30+
* Used for pruning old entries.
31+
*
32+
* @param user the user
33+
* @return list of password history entries
34+
*/
35+
List<PasswordHistoryEntry> findByUserOrderByEntryDateDesc(User user);
36+
}

0 commit comments

Comments
 (0)