Added email reset code and follow request.

This commit is contained in:
Andrew Lalis 2023-03-24 16:21:03 +01:00
parent bc1e2b4397
commit 3653fe697e
17 changed files with 385 additions and 27 deletions

View File

@ -5,7 +5,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.2</version>
<version>3.0.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>nl.andrewlalis</groupId>
@ -51,25 +51,25 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.9.0</version>
<version>1.10.0</version>
</dependency>
<!-- JWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
@ -82,7 +82,7 @@
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.1.1</version>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->

View File

@ -15,6 +15,11 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* A filter that performs token authentication on incoming requests, and if a
* user's token is valid, sets the security context's authentication to a new
* instance of {@link TokenAuthentication}.
*/
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;

View File

@ -53,6 +53,18 @@ public class UserController {
return ResponseEntity.ok().build();
}
@PostMapping(path = "/auth/me/email-reset-code")
public ResponseEntity<Void> generateEmailResetCode(@AuthenticationPrincipal User user, @RequestBody EmailUpdatePayload payload) {
userService.generateEmailResetCode(user.getId(), payload);
return ResponseEntity.ok().build();
}
@PostMapping(path = "/auth/me/email")
public ResponseEntity<Void> updateMyEmail(@AuthenticationPrincipal User user, @RequestParam String code) {
userService.updateEmail(user.getId(), code);
return ResponseEntity.ok().build();
}
@GetMapping(path = "/auth/me/personal-details")
public UserPersonalDetailsResponse getMyPersonalDetails(@AuthenticationPrincipal User user) {
return userService.getPersonalDetails(user.getId());
@ -85,9 +97,8 @@ public class UserController {
}
@PostMapping(path = "/auth/users/{userId}/followers")
public ResponseEntity<Void> followUser(@AuthenticationPrincipal User myUser, @PathVariable String userId) {
userService.followUser(myUser.getId(), userId);
return ResponseEntity.ok().build();
public UserFollowResponse followUser(@AuthenticationPrincipal User myUser, @PathVariable String userId) {
return userService.followUser(myUser.getId(), userId);
}
@DeleteMapping(path = "/auth/users/{userId}/followers")
@ -96,13 +107,23 @@ public class UserController {
return ResponseEntity.ok().build();
}
@PostMapping(path = "/auth/me/follow-requests/{followRequestId}")
public ResponseEntity<Void> respondToFollowRequest(
@AuthenticationPrincipal User myUser,
@PathVariable long followRequestId,
@RequestBody UserFollowRequestApproval payload
) {
userService.respondToFollowRequest(myUser.getId(), followRequestId, payload.approve());
return ResponseEntity.ok().build();
}
@GetMapping(path = "/auth/me/followers")
public Page<UserResponse> getFollowers(@AuthenticationPrincipal User user, Pageable pageable) {
public Page<UserResponse> getMyFollowers(@AuthenticationPrincipal User user, Pageable pageable) {
return userService.getFollowers(user.getId(), pageable);
}
@GetMapping(path = "/auth/me/following")
public Page<UserResponse> getFollowing(@AuthenticationPrincipal User user, Pageable pageable) {
public Page<UserResponse> getMyFollowing(@AuthenticationPrincipal User user, Pageable pageable) {
return userService.getFollowing(user.getId(), pageable);
}

View File

@ -0,0 +1,14 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
}

View File

@ -0,0 +1,14 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface UserFollowRequestRepository extends JpaRepository<UserFollowRequest, Long> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
}

View File

@ -17,4 +17,7 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);
long countByFollowedUser(User followedUser);
long countByFollowingUser(User followingUser);
}

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record EmailUpdatePayload(String newEmail) {}

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record UserFollowRequestApproval(boolean approve) {}

View File

@ -0,0 +1,24 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
/**
* A response that's sent when a user requests to follow another.
*/
public record UserFollowResponse(
String result
) {
private static final String RESULT_FOLLOWED = "FOLLOWED";
private static final String RESULT_REQUESTED = "REQUESTED";
private static final String RESULT_ALREADY_FOLLOWED = "ALREADY_FOLLOWED";
public static UserFollowResponse followed() {
return new UserFollowResponse(RESULT_FOLLOWED);
}
public static UserFollowResponse requested() {
return new UserFollowResponse(RESULT_REQUESTED);
}
public static UserFollowResponse alreadyFollowed() {
return new UserFollowResponse(RESULT_ALREADY_FOLLOWED);
}
}

View File

@ -0,0 +1,54 @@
package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* A code that's sent to a user's new email address to confirm that they own
* it. Once confirmed, the user's email address will be updated.
*/
@Table(name = "auth_email_reset_code")
@Entity
public class EmailResetCode {
public static final Duration VALID_FOR = Duration.ofMinutes(30);
@Id
@Column(nullable = false, updatable = false, length = 127)
private String code;
@Column(nullable = false, updatable = false, unique = true)
private String newEmail;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user;
public EmailResetCode() {}
public EmailResetCode(String code, String newEmail, User user) {
this.code = code;
this.newEmail = newEmail;
this.user = user;
}
public String getCode() {
return code;
}
public String getNewEmail() {
return newEmail;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public User getUser() {
return user;
}
}

View File

@ -6,6 +6,10 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* A code that's sent to a user's email address to grant them access to change
* their password without needing to log in.
*/
@Entity
@Table(name = "auth_user_password_reset_code")
public class PasswordResetCode {

View File

@ -6,6 +6,13 @@ import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.Collections;
/**
* The authentication instance that's used to represent a user who has
* authenticated with an API token (so most users).
* @param user The user who authenticated. The user entity has its roles eagerly
* loaded.
* @param token The token that was used.
*/
public record TokenAuthentication(
User user,
String token

View File

@ -76,6 +76,10 @@ public class User {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}

View File

@ -0,0 +1,73 @@
package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* A request that one user sends to ask to follow another user.
*/
@Table(
name = "auth_user_follow_request",
uniqueConstraints = @UniqueConstraint(columnNames = {"requesting_user_id", "user_to_follow_id"})
)
@Entity
public class UserFollowRequest {
public static final Duration VALID_FOR = Duration.ofDays(7);
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User requestingUser;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User userToFollow;
@Column
private Boolean approved;
@Column
private LocalDateTime decidedAt;
public UserFollowRequest() {}
public UserFollowRequest(User requestingUser, User userToFollow) {
this.requestingUser = requestingUser;
this.userToFollow = userToFollow;
}
public Long getId() {
return id;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public User getRequestingUser() {
return requestingUser;
}
public User getUserToFollow() {
return userToFollow;
}
public Boolean getApproved() {
return approved;
}
public LocalDateTime getDecidedAt() {
return decidedAt;
}
public void setApproved(boolean approved) {
this.approved = approved;
this.decidedAt = LocalDateTime.now();
}
}

View File

@ -5,6 +5,10 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* A user report is submitted by one user to report inappropriate actions of
* another user.
*/
@Entity
@Table(name = "auth_user_report")
public class UserReport {

View File

@ -28,6 +28,8 @@ import java.time.LocalDateTime;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import static nl.andrewlalis.gymboard_api.util.DataUtils.findByIdOrThrow;
@Service
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
@ -37,7 +39,9 @@ public class UserService {
private final UserPreferencesRepository userPreferencesRepository;
private final UserActivationCodeRepository activationCodeRepository;
private final PasswordResetCodeRepository passwordResetCodeRepository;
private final EmailResetCodeRepository emailResetCodeRepository;
private final UserFollowingRepository userFollowingRepository;
private final UserFollowRequestRepository followRequestRepository;
private final UserAccessService userAccessService;
private final ULID ulid;
private final PasswordEncoder passwordEncoder;
@ -52,7 +56,10 @@ public class UserService {
UserPreferencesRepository userPreferencesRepository,
UserActivationCodeRepository activationCodeRepository,
PasswordResetCodeRepository passwordResetCodeRepository,
UserFollowingRepository userFollowingRepository, UserAccessService userAccessService, ULID ulid,
EmailResetCodeRepository emailResetCodeRepository, UserFollowingRepository userFollowingRepository,
UserFollowRequestRepository followRequestRepository,
UserAccessService userAccessService,
ULID ulid,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender
) {
@ -61,7 +68,9 @@ public class UserService {
this.userPreferencesRepository = userPreferencesRepository;
this.activationCodeRepository = activationCodeRepository;
this.passwordResetCodeRepository = passwordResetCodeRepository;
this.emailResetCodeRepository = emailResetCodeRepository;
this.userFollowingRepository = userFollowingRepository;
this.followRequestRepository = followRequestRepository;
this.userAccessService = userAccessService;
this.ulid = ulid;
this.passwordEncoder = passwordEncoder;
@ -218,6 +227,59 @@ public class UserService {
userRepository.save(user);
}
@Transactional
public void generateEmailResetCode(String id, EmailUpdatePayload payload) {
User user = findByIdOrThrow(id, userRepository);
if (userRepository.existsByEmail(payload.newEmail())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken.");
}
EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
payload.newEmail(),
user
));
String emailContent = String.format(
"""
<p>Hello %s,</p>
<p>
You've just requested to change your email from %s to this email address.
</p>
<p>
Please click enter this code to reset your email: %s
</p>
""",
user.getName(),
user.getEmail(),
emailResetCode.getCode()
);
MimeMessage msg = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8");
helper.setFrom("Gymboard <noreply@gymboard.io>");
helper.setSubject("Gymboard Account Email Update");
helper.setTo(emailResetCode.getNewEmail());
helper.setText(emailContent, true);
mailSender.send(msg);
} catch (MessagingException e) {
log.error("Error sending user email update email.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Transactional
public void updateEmail(String userId, String code) {
User user = findByIdOrThrow(userId, userRepository);
EmailResetCode emailResetCode = findByIdOrThrow(code, emailResetCodeRepository);
if (!emailResetCode.getUser().getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
user.setEmail(emailResetCode.getNewEmail());
userRepository.save(user);
emailResetCodeRepository.delete(emailResetCode);
}
/**
* Scheduled task that periodically removes all old authentication entities
* so that they don't clutter up the system.
@ -229,6 +291,10 @@ public class UserService {
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
}
@Transactional
@ -280,30 +346,64 @@ public class UserService {
return new UserPreferencesResponse(p);
}
/**
* When a user indicates that they'd like to follow another, this method is
* invoked. If the person they want to follow is private, we create a new
* {@link UserFollowRequest} that the person must approve. Otherwise, the
* user just starts following the person. A 400 bad request is thrown if the
* user tries to follow themselves.
* @param followerId The id of the user that's trying to follow a user.
* @param followedId The id of the user that's being followed.
* @return A response that indicates the outcome.
*/
@Transactional
public void followUser(String followerId, String followedId) {
if (followerId.equals(followedId)) return;
User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
public UserFollowResponse followUser(String followerId, String followedId) {
if (followerId.equals(followedId)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "You can't follow yourself.");
User follower = findByIdOrThrow(followerId, userRepository);
User followed = findByIdOrThrow(followedId, userRepository);
if (!userFollowingRepository.existsByFollowedUserAndFollowingUser(followed, follower)) {
if (followed.getPreferences().isAccountPrivate()) {
userFollowingRepository.save(new UserFollowing(followed, follower));
return UserFollowResponse.requested();
} else {
followRequestRepository.save(new UserFollowRequest(follower, followed));
return UserFollowResponse.followed();
}
}
return UserFollowResponse.alreadyFollowed();
}
@Transactional
public void unfollowUser(String followerId, String followedId) {
if (followerId.equals(followedId)) return;
User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User follower = findByIdOrThrow(followerId, userRepository);
User followed = findByIdOrThrow(followedId, userRepository);
userFollowingRepository.deleteByFollowedUserAndFollowingUser(followed, follower);
}
@Transactional
public void respondToFollowRequest(String userId, long followRequestId, boolean approved) {
User followedUser = findByIdOrThrow(userId, userRepository);
UserFollowRequest followRequest = findByIdOrThrow(followRequestId, followRequestRepository);
if (!followRequest.getUserToFollow().getId().equals(followedUser.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
if (followRequest.getApproved() != null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Request already decided.");
}
followRequest.setApproved(approved);
followRequestRepository.save(followRequest);
if (approved) {
userFollowingRepository.save(new UserFollowing(followedUser, followRequest.getRequestingUser()));
// TODO: Send notification to the user who requested to follow.
}
}
@Transactional(readOnly = true)
public Page<UserResponse> getFollowers(String userId, Pageable pageable) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User user = findByIdOrThrow(userId, userRepository);
userAccessService.enforceUserAccess(user);
return userFollowingRepository.findAllByFollowedUserOrderByCreatedAtDesc(user, pageable)
.map(UserFollowing::getFollowingUser)
@ -312,20 +412,25 @@ public class UserService {
@Transactional(readOnly = true)
public Page<UserResponse> getFollowing(String userId, Pageable pageable) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User user = findByIdOrThrow(userId, userRepository);
userAccessService.enforceUserAccess(user);
return userFollowingRepository.findAllByFollowingUserOrderByCreatedAtDesc(user, pageable)
.map(UserFollowing::getFollowedUser)
.map(UserResponse::new);
}
public long getFollowerCount(String userId) {
return userFollowingRepository.countByFollowedUser(findByIdOrThrow(userId, userRepository));
}
public long getFollowingCount(String userId) {
return userFollowingRepository.countByFollowingUser(findByIdOrThrow(userId, userRepository));
}
@Transactional(readOnly = true)
public UserRelationshipResponse getRelationship(String user1Id, String user2Id) {
User user1 = userRepository.findById(user1Id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User user2 = userRepository.findById(user2Id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
User user1 = findByIdOrThrow(user1Id, userRepository);
User user2 = findByIdOrThrow(user2Id, userRepository);
userAccessService.enforceUserAccess(user1);
boolean user1FollowingUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user2, user1);
boolean user1FollowedByUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user1, user2);

View File

@ -0,0 +1,20 @@
package nl.andrewlalis.gymboard_api.util;
import org.springframework.data.repository.CrudRepository;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class DataUtils {
/**
* Finds an entity by its id, or throws a 404 not found exception.
* @param id The id to look for.
* @param repo The repository to search in.
* @return The entity that was found.
* @param <T> The entity type.
* @param <ID> The id type.
* @throws ResponseStatusException If the entity wasn't found.
*/
public static <T, ID> T findByIdOrThrow(ID id, CrudRepository<T, ID> repo) throws ResponseStatusException {
return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}