From 5b0513250c194ca5dfb8c6781adbd3dfe02d12ec Mon Sep 17 00:00:00 2001 From: Kaiser-Yang <624626089@qq.com> Date: Sun, 15 Sep 2024 19:03:54 +0800 Subject: [PATCH] Add an api for update user information During development, we found that the `Long` can not be expressed correctly in the `Swagger` document, so we use `String` to represent the `id` and convert it to `Long` when we use it as the field of `UserPO`. We also found that the `HttpServletRequest`'s `getReader()` and `getInputStream()` can only be called once, so we need to cache the request body in the `JwtFilter` to make it can be read multiple times. During this commit, we update the header parameter `Token` with the `Access-Token` and `Refresh-Token` to make it more clear. See #32. --- .../cmipt/gcs/constant/ApiPathConstant.java | 1 + .../cmipt/gcs/constant/HeaderParameter.java | 1 - .../gcs/constant/ValidationConstant.java | 4 + .../controller/AuthenticationController.java | 34 +++-- .../cmipt/gcs/controller/UserController.java | 61 +++++++- .../cmipt/gcs/enumeration/ErrorCodeEnum.java | 7 +- .../gcs/exception/GlobalExceptionHandler.java | 9 ++ .../java/edu/cmipt/gcs/filter/JwtFilter.java | 140 ++++++++++++++---- .../java/edu/cmipt/gcs/pojo/user/UserDTO.java | 20 ++- .../java/edu/cmipt/gcs/pojo/user/UserPO.java | 12 +- .../java/edu/cmipt/gcs/pojo/user/UserVO.java | 5 +- src/main/java/edu/cmipt/gcs/util/JwtUtil.java | 18 ++- src/main/resources/message/message.properties | 5 + .../edu/cmipt/gcs/constant/TestConstant.java | 1 + .../AuthenticationControllerTest.java | 12 +- .../gcs/controller/UserControllerTest.java | 71 ++++++++- 16 files changed, 322 insertions(+), 79 deletions(-) diff --git a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java index b804b98..8fec2d2 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java @@ -21,6 +21,7 @@ public class ApiPathConstant { public static final String USER_API_PREFIX = ALL_API_PREFIX + "/user"; public static final String USER_GET_USER_BY_NAME_API_PATH = USER_API_PREFIX + "/{username}"; + public static final String USER_UPDATE_USER_API_PATH = USER_API_PREFIX + "/update"; public static final String USER_CHECK_EMAIL_VALIDITY_API_PATH = USER_API_PREFIX + "/email"; public static final String USER_CHECK_USERNAME_VALIDITY_API_PATH = USER_API_PREFIX + "/username"; diff --git a/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java index 030a88c..030d655 100644 --- a/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java +++ b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java @@ -1,7 +1,6 @@ package edu.cmipt.gcs.constant; public class HeaderParameter { - public static final String TOKEN = "Token"; public static final String ACCESS_TOKEN = "Access-Token"; public static final String REFRESH_TOKEN = "Refresh-Token"; } diff --git a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java index d4430c7..ace037c 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java @@ -5,4 +5,8 @@ public class ValidationConstant { public static final int MAX_PASSWORD_LENGTH = 20; public static final int MIN_USERNAME_LENGTH = 1; public static final int MAX_USERNAME_LENGTH = 50; + // the size of username and password will be check by the @Size, + // so we just user '*' to ignore the length check + public static final String USERNAME_PATTERN = "^[a-zA-Z0-9_]*$"; + public static final String PASSWORD_PATTERN = "^[a-zA-Z0-9_.@]*$"; } diff --git a/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java index cdb15f8..d46d761 100644 --- a/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java +++ b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -105,13 +106,7 @@ public ResponseEntity signIn(@Validated @RequestBody UserSignInDTO user) throw new GenericException(ErrorCodeEnum.WRONG_SIGN_IN_INFORMATION); } UserVO userVO = new UserVO(userService.getOne(wrapper)); - HttpHeaders headers = new HttpHeaders(); - headers.add( - HeaderParameter.ACCESS_TOKEN, - JwtUtil.generateToken(userVO.id(), TokenTypeEnum.ACCESS_TOKEN)); - headers.add( - HeaderParameter.REFRESH_TOKEN, - JwtUtil.generateToken(userVO.id(), TokenTypeEnum.REFRESH_TOKEN)); + HttpHeaders headers = JwtUtil.generateHeaders(userVO.id()); return ResponseEntity.ok().headers(headers).body(userVO); } @@ -130,12 +125,20 @@ public void signOut(@RequestBody List tokenList) { summary = "Refresh token", description = "Return an access token with given refresh token", tags = {"Authentication", "Get Method"}) - @Parameter( - name = HeaderParameter.TOKEN, - description = "Refresh token", - required = true, - in = ParameterIn.HEADER, - schema = @Schema(implementation = String.class)) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = HeaderParameter.REFRESH_TOKEN, + description = "Refresh token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) @ApiResponses({ @ApiResponse( responseCode = "200", @@ -143,11 +146,12 @@ public void signOut(@RequestBody List tokenList) { content = @Content(schema = @Schema(implementation = String.class))), @ApiResponse(responseCode = "500", description = "Internal server error") }) - public ResponseEntity refreshToken(@RequestHeader(HeaderParameter.TOKEN) String token) { + public ResponseEntity refreshToken(@RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, @RequestHeader(HeaderParameter.REFRESH_TOKEN) String refreshToken) { + JwtUtil.blacklistToken(accessToken); HttpHeaders headers = new HttpHeaders(); headers.add( HeaderParameter.ACCESS_TOKEN, - JwtUtil.generateToken(JwtUtil.getID(token), TokenTypeEnum.ACCESS_TOKEN)); + JwtUtil.generateToken(JwtUtil.getID(refreshToken), TokenTypeEnum.ACCESS_TOKEN)); return ResponseEntity.ok().headers(headers).build(); } } diff --git a/src/main/java/edu/cmipt/gcs/controller/UserController.java b/src/main/java/edu/cmipt/gcs/controller/UserController.java index d6f5272..096150f 100644 --- a/src/main/java/edu/cmipt/gcs/controller/UserController.java +++ b/src/main/java/edu/cmipt/gcs/controller/UserController.java @@ -6,12 +6,15 @@ import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.constant.ValidationConstant; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.enumeration.TokenTypeEnum; import edu.cmipt.gcs.exception.GenericException; import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.pojo.user.UserDTO; import edu.cmipt.gcs.pojo.user.UserPO; import edu.cmipt.gcs.pojo.user.UserVO; import edu.cmipt.gcs.service.UserService; - +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.validation.group.UpdateGroup; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; @@ -21,15 +24,19 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; - import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -46,7 +53,7 @@ public class UserController { tags = {"User", "Get Method"}) @Parameters({ @Parameter( - name = HeaderParameter.TOKEN, + name = HeaderParameter.ACCESS_TOKEN, description = "Access token", required = true, in = ParameterIn.HEADER, @@ -75,6 +82,50 @@ public UserVO getUserByName(@PathVariable("username") String username) { return new UserVO(userService.getOne(wrapper)); } + @PostMapping(ApiPathConstant.USER_UPDATE_USER_API_PATH) + @Operation( + summary = "Update user", + description = "Update user information", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User information updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User information update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = HeaderParameter.REFRESH_TOKEN, + description = "Refresh token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)) + }) + public ResponseEntity updateUser(@RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, @RequestHeader(HeaderParameter.REFRESH_TOKEN) String refreshToken, @Validated(UpdateGroup.class) @RequestBody UserDTO user) { + if (user.username() != null) { checkUsernameValidity(user.username()); } + if (user.email() != null) { checkEmailValidity(user.email()); } + // for the null fields, mybatis-plus will ignore by default + assert user.id() != null; + boolean res = userService.updateById(new UserPO(user)); + if (!res) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, user.toString()); + } + UserVO userVO = new UserVO(userService.getById(Long.valueOf(user.id()))); + HttpHeaders headers = null; + if (user.userPassword() != null) { + JwtUtil.blacklistToken(accessToken, refreshToken); + headers = JwtUtil.generateHeaders(userVO.id()); + } + return ResponseEntity.ok().headers(headers).body(userVO); + } + @GetMapping(ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH) @Operation( summary = "Check email validity", @@ -90,7 +141,7 @@ public UserVO getUserByName(@PathVariable("username") String username) { @ApiResponses({ @ApiResponse(responseCode = "200", description = "Email validity checked successfully"), @ApiResponse( - responseCode = "403", + responseCode = "400", description = "Email is invalid", content = @Content(schema = @Schema(implementation = ErrorVO.class))) }) @@ -121,7 +172,7 @@ public void checkEmailValidity( @ApiResponses({ @ApiResponse(responseCode = "200", description = "Username validity checked successfully"), @ApiResponse( - responseCode = "403", + responseCode = "400", description = "Username is not valid", content = @Content(schema = @Schema(implementation = ErrorVO.class))) }) diff --git a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java index 2b87265..847b9f1 100644 --- a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java +++ b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java @@ -16,6 +16,9 @@ public enum ErrorCodeEnum { USERSIGNINDTO_USERNAME_NOTBLANK("UserSignInDTO.username.NotBlank"), USERSIGNINDTO_USERPASSWORD_NOTBLANK("UserSignInDTO.userPassword.NotBlank"), + USERNAME_PATTERN_MISMATCH("USERNAME_PATTERN_MISMATCH"), + PASSWORD_PATTERN_MISMATCH("PASSWORD_PATTERN_MISMATCH"), + USERNAME_ALREADY_EXISTS("USERNAME_ALREADY_EXISTS"), EMAIL_ALREADY_EXISTS("EMAIL_ALREADY_EXISTS"), WRONG_SIGN_IN_INFORMATION("WRONG_SIGN_IN_INFORMATION"), @@ -26,7 +29,9 @@ public enum ErrorCodeEnum { MESSAGE_CONVERSION_ERROR("MESSAGE_CONVERSION_ERROR"), - USER_NOT_FOUND("USER_NOT_FOUND"); + USER_NOT_FOUND("USER_NOT_FOUND"), + + USER_UPDATE_FAILED("USER_UPDATE_FAILED"); // code means the error code in the message.properties private String code; diff --git a/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java index 97fc660..1ffeaf9 100644 --- a/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java +++ b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.json.JsonParseException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -81,6 +82,14 @@ public ResponseEntity handleGenericException( } } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(JsonParseException.class) + public void handleJsonParseException(JsonParseException e, HttpServletRequest request) { + GenericException exception = new GenericException(e.getMessage()); + exception.setCode(ErrorCodeEnum.MESSAGE_CONVERSION_ERROR); + handleGenericException(exception, request); + } + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) public void handleException(Exception e) { diff --git a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java index 11d812a..6eb7636 100644 --- a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java +++ b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java @@ -3,24 +3,35 @@ import edu.cmipt.gcs.constant.ApiPathConstant; import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.enumeration.TokenTypeEnum; import edu.cmipt.gcs.exception.GenericException; import edu.cmipt.gcs.util.JwtUtil; import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.springframework.boot.json.JsonParserFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * JwtFilter * @@ -31,6 +42,66 @@ @Component @Order(Ordered.LOWEST_PRECEDENCE) public class JwtFilter extends OncePerRequestFilter { + /** + * CachedBodyHttpServletRequest + * + * The {@link}getInputStream() and {@link}getReader() methods of {@link}HttpServletRequest can only be called once. + * This class is used to cache the body of the request so that it can be read multiple times. + */ + private class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + private class CachedBodyServletInputStream extends ServletInputStream { + private final InputStream cacheBodyInputStream; + + public CachedBodyServletInputStream(byte[] cacheBody) { + this.cacheBodyInputStream = new ByteArrayInputStream(cacheBody); + } + + @Override + public boolean isFinished() { + try { + return cacheBodyInputStream.available() == 0; + } catch (IOException e) { + return true; + } + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() throws IOException { + return cacheBodyInputStream.read(); + } + } + + private final byte[] cacheBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + InputStream requestInputStream = request.getInputStream(); + this.cacheBody = StreamUtils.copyToByteArray(requestInputStream); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(this.cacheBody); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(this.cacheBody))); + } + } + + private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class); + private Set ignorePath = Set.of( ApiPathConstant.AUTHENTICATION_SIGN_UP_API_PATH, @@ -52,45 +123,52 @@ protected void doFilterInternal( return; } // throw exception if authorization failed - authorize(request, request.getHeader(HeaderParameter.TOKEN)); - filterChain.doFilter(request, response); + CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request); + authorize(cachedRequest, cachedRequest.getHeader(HeaderParameter.ACCESS_TOKEN), cachedRequest.getHeader(HeaderParameter.REFRESH_TOKEN)); + filterChain.doFilter(cachedRequest, response); } - private void authorize(HttpServletRequest request, String token) { - if (token == null) { - throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + private void authorize(HttpServletRequest request, String accessToken, String refreshToken) { + if (accessToken != null && JwtUtil.getTokenType(accessToken) != TokenTypeEnum.ACCESS_TOKEN) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, accessToken); } - switch (JwtUtil.getTokenType(token)) { - case ACCESS_TOKEN: - // ACCESS_TOKEN can not be used for refresh - if (request.getRequestURI() - .equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH)) { - throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); - } - String idInToken = JwtUtil.getID(token); - switch (request.getMethod()) { - case "GET": - break; - case "POST": - // User can not update other user's information - if (request.getRequestURI().startsWith(ApiPathConstant.USER_API_PREFIX) - && !idInToken.equals(getFromRequestBody(request, "id"))) { - throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); - } - break; - default: - throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + if (refreshToken != null && JwtUtil.getTokenType(refreshToken) != TokenTypeEnum.REFRESH_TOKEN) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, refreshToken); + } + switch (request.getMethod()) { + case "GET": + if ((accessToken == null && !request.getRequestURI().equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH)) || + (refreshToken == null && request.getRequestURI().equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH))) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); } break; - case REFRESH_TOKEN: - // REFRESH_TOKEN can only be used for refresh - if (!request.getRequestURI() - .equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH)) { - throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + case "POST": + if (accessToken == null) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } + if (request.getRequestURI().equals(ApiPathConstant.USER_UPDATE_USER_API_PATH)) { + // for update user information, both access token and refresh token are needed + if (accessToken == null || refreshToken == null) { + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } + // User can not update other user's information + String idInToken = JwtUtil.getID(accessToken); + String idInBody = getFromRequestBody(request, "id"); + if (request.getRequestURI().startsWith(ApiPathConstant.USER_API_PREFIX) + && !idInToken.equals(idInBody)) { + logger.info("User[{}] tried to update user[{}]'s information", idInToken, idInBody); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else if (request.getRequestURI().equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) && + (accessToken == null || refreshToken == null)) { + // for refresh token, both access token and refresh token are needed + throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } else { + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } break; default: - throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } } diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java index 163d219..6081ec6 100644 --- a/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java @@ -5,11 +5,11 @@ import edu.cmipt.gcs.validation.group.UpdateGroup; import io.swagger.v3.oas.annotations.media.Schema; - import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; /** @@ -21,32 +21,36 @@ public record UserDTO( @Schema( description = "User ID", - requiredMode = Schema.RequiredMode.NOT_REQUIRED, accessMode = Schema.AccessMode.READ_ONLY) @Null(groups = CreateGroup.class, message = "USERDTO_ID_NULL {UserDTO.id.Null}") @NotNull( groups = UpdateGroup.class, message = "USERDTO_ID_NOTNULL {UserDTO.id.NotNull}") - Long id, + // The Long can not be expressed correctly in json, so use String instead + String id, @Schema( description = "Username", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @Size( - groups = {CreateGroup.class}, + groups = {CreateGroup.class, UpdateGroup.class}, min = ValidationConstant.MIN_USERNAME_LENGTH, max = ValidationConstant.MAX_USERNAME_LENGTH, message = "USERDTO_USERNAME_SIZE {UserDTO.username.Size}") @NotBlank( groups = {CreateGroup.class}, message = "USERDTO_USERNAME_NOTBLANK {UserDTO.username.NotBlank}") + @Pattern( + regexp = ValidationConstant.USERNAME_PATTERN, + groups = {CreateGroup.class, UpdateGroup.class}, + message = "USERNAME_PATTERN_MISMATCH {USERNAME_PATTERN_MISMATCH}") String username, @Schema( description = "Email", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin@cmipt.edu") @Email( - groups = {CreateGroup.class}, + groups = {CreateGroup.class, UpdateGroup.class}, message = "USERDTO_EMAIL_EMAIL {UserDTO.email.Email}") @NotBlank( groups = {CreateGroup.class}, @@ -57,11 +61,15 @@ public record UserDTO( requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") @Size( - groups = {CreateGroup.class}, + groups = {CreateGroup.class, UpdateGroup.class}, min = ValidationConstant.MIN_PASSWORD_LENGTH, max = ValidationConstant.MAX_PASSWORD_LENGTH, message = "USERDTO_USERPASSWORD_SIZE {UserDTO.userPassword.Size}") @NotBlank( groups = {CreateGroup.class}, message = "USERDTO_USERPASSWORD_NOTBLANK {UserDTO.userPassword.NotBlank}") + @Pattern( + regexp = ValidationConstant.PASSWORD_PATTERN, + groups = {CreateGroup.class, UpdateGroup.class}, + message = "PASSWORD_PATTERN_MISMATCH {PASSWORD_PATTERN_MISMATCH}") String userPassword) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java index dc28a9a..3f6ac96 100644 --- a/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserPO.java @@ -22,14 +22,14 @@ public class UserPO { private LocalDateTime gmtUpdated; @TableLogic private LocalDateTime gmtDeleted; - public UserPO(UserDTO userDTO, Long id) { - this.id = id; + public UserPO(UserDTO userDTO) { + try { + this.id = Long.valueOf(userDTO.id()); + } catch (NumberFormatException e) { + this.id = null; + } this.username = userDTO.username(); this.email = userDTO.email(); this.userPassword = MD5Converter.convertToMD5(userDTO.userPassword()); } - - public UserPO(UserDTO userDTO) { - this(userDTO, null); - } } diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java index bc19589..30c77f4 100644 --- a/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserVO.java @@ -4,10 +4,11 @@ @Schema(description = "User Value Object") public record UserVO( - @Schema(description = "User ID") Long id, + // The Long can not be expressed correctly in json, so use String instead + @Schema(description = "User ID") String id, @Schema(description = "Username", example = "admin") String username, @Schema(description = "Email", example = "admin@cmipt.edu") String email) { public UserVO(UserPO userPO) { - this(userPO.getId(), userPO.getUsername(), userPO.getEmail()); + this(userPO.getId().toString(), userPO.getUsername(), userPO.getEmail()); } } diff --git a/src/main/java/edu/cmipt/gcs/util/JwtUtil.java b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java index 51f5135..003920f 100644 --- a/src/main/java/edu/cmipt/gcs/util/JwtUtil.java +++ b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java @@ -1,6 +1,7 @@ package edu.cmipt.gcs.util; import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; import edu.cmipt.gcs.enumeration.TokenTypeEnum; import edu.cmipt.gcs.exception.GenericException; @@ -12,6 +13,8 @@ import javax.crypto.SecretKey; +import org.springframework.http.HttpHeaders; + /** * JwtUtil * @@ -78,19 +81,24 @@ public static TokenTypeEnum getTokenType(String token) { } } + public static HttpHeaders generateHeaders(String id) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HeaderParameter.ACCESS_TOKEN, generateToken(id, TokenTypeEnum.ACCESS_TOKEN)); + headers.add(HeaderParameter.REFRESH_TOKEN, generateToken(id, TokenTypeEnum.REFRESH_TOKEN)); + return headers; + } + /** * Add token to blacklist * * @author Kaiser - * @param token + * @param tokens */ - public static void blacklistToken(String token) { + public static void blacklistToken(String... tokens) { // TODO: add token to blacklist, we will consider this later } public static void blacklistToken(List tokenList) { - for (String token : tokenList) { - blacklistToken(token); - } + blacklistToken(tokenList.toArray(new String[0])); } } diff --git a/src/main/resources/message/message.properties b/src/main/resources/message/message.properties index d226078..97ce25e 100644 --- a/src/main/resources/message/message.properties +++ b/src/main/resources/message/message.properties @@ -12,6 +12,9 @@ UserDTO.userPassword.NotBlank=Password cannot be blank UserSignInDTO.username.NotBlank=Username cannot be blank UserSignInDTO.userPassword.NotBlank=Password cannot be blank +USERNAME_PATTERN_MISMATCH=Username can only be alphanumeric or underline +PASSWORD_PATTERN_MISMATCH=Password can only be alphanumeric, dot, at sign or underline + USERNAME_ALREADY_EXISTS=Username already exists: {} EMAIL_ALREADY_EXISTS=Email already exists: {} WRONG_SIGN_IN_INFORMATION=Wrong sign in information @@ -23,3 +26,5 @@ ACCESS_DENIED=Operation without privileges MESSAGE_CONVERSION_ERROR=Error occurs while converting message USER_NOT_FOUND=User not found: {} + +USER_UPDATE_FAILED=User update failed: {} diff --git a/src/test/java/edu/cmipt/gcs/constant/TestConstant.java b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java index e0d8811..517eb51 100644 --- a/src/test/java/edu/cmipt/gcs/constant/TestConstant.java +++ b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java @@ -3,6 +3,7 @@ import java.util.Date; public class TestConstant { + public static String ID; public static String USERNAME = new Date().getTime() + ""; public static String USER_PASSWORD = "123456"; public static String EMAIL = USERNAME + "@cmipt.edu"; diff --git a/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java index 8036c15..f873bb6 100644 --- a/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java +++ b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.json.JsonParserFactory; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.Ordered; @@ -110,13 +111,14 @@ public void testSignInValid() throws Exception { status().isOk(), jsonPath("$.username", is(TestConstant.USERNAME)), jsonPath("$.email", is(TestConstant.EMAIL)), - jsonPath("$.id").isNumber(), + jsonPath("$.id").isString(), header().exists(HeaderParameter.ACCESS_TOKEN), header().exists(HeaderParameter.REFRESH_TOKEN)) .andReturn() .getResponse(); TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + TestConstant.ID = JsonParserFactory.getJsonParser().parseMap(response.getContentAsString()).get("id").toString(); } /** @@ -131,7 +133,8 @@ public void testSignInValid() throws Exception { public void testRefreshValid() throws Exception { mvc.perform( get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) - .header(HeaderParameter.TOKEN, TestConstant.REFRESH_TOKEN)) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN)) .andExpectAll(status().isOk(), header().exists(HeaderParameter.ACCESS_TOKEN)); } @@ -184,10 +187,11 @@ public void testSignUpInvalid() throws Exception { @Test public void testRefreshInvalid() throws Exception { - String invalidToken = "This is a invalid token"; + String invalidToken = "This is an invalid token"; mvc.perform( get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) - .header(HeaderParameter.TOKEN, invalidToken)) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, invalidToken)) .andExpectAll( status().isUnauthorized(), content() diff --git a/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java index b64bd4f..e159631 100644 --- a/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java +++ b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java @@ -2,7 +2,9 @@ import static org.hamcrest.Matchers.is; 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.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -16,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.util.Date; @@ -34,12 +37,12 @@ public class UserControllerTest { public void testGetUserByNameValid() throws Exception { mvc.perform( get(ApiPathConstant.USER_API_PREFIX + "/" + TestConstant.USERNAME) - .header(HeaderParameter.TOKEN, TestConstant.ACCESS_TOKEN)) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN)) .andExpectAll( status().isOk(), jsonPath("$.username", is(TestConstant.USERNAME)), jsonPath("$.email", is(TestConstant.EMAIL)), - jsonPath("$.id").isNumber()); + jsonPath("$.id").isString()); } @Test @@ -47,7 +50,7 @@ public void testGetUserByNameInvalid() throws Exception { String invalidUsername = TestConstant.USERNAME + "invalid"; mvc.perform( get(ApiPathConstant.USER_API_PREFIX + "/" + invalidUsername) - .header(HeaderParameter.TOKEN, TestConstant.ACCESS_TOKEN)) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN)) .andExpectAll( status().isNotFound(), content() @@ -127,4 +130,66 @@ public void testCheckUsernameValidityValid() throws Exception { .param("username", new Date().getTime() + "")) .andExpectAll(status().isOk()); } + + @Test + public void testUpdateUserValid() throws Exception { + TestConstant.USERNAME += new Date().getTime() + "new"; + TestConstant.EMAIL = TestConstant.USERNAME + "@cmipt.edu"; + TestConstant.USER_PASSWORD += "new"; + var response = mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "username": "%s", + "email": "%s", + "userPassword": "%s" + } + """.formatted(TestConstant.ID, TestConstant.USERNAME, TestConstant.EMAIL, TestConstant.USER_PASSWORD))) + .andExpectAll( + status().isOk(), + header().exists(HeaderParameter.ACCESS_TOKEN), + header().exists(HeaderParameter.REFRESH_TOKEN), + jsonPath("$.username", is(TestConstant.USERNAME)), + jsonPath("$.email", is(TestConstant.EMAIL)), + jsonPath("$.id").isString()) + .andReturn() + .getResponse(); + // make sure the new information is updated + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + @Test + public void testUpdateUserInvalid() throws Exception { + String otherID = "123"; + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "username": "%s", + "email": "%s", + "userPassword": "%s" + } + """.formatted(otherID, TestConstant.USERNAME, TestConstant.EMAIL, TestConstant.USER_PASSWORD))) + .andExpectAll( + status().isForbidden(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """.formatted(ErrorCodeEnum.ACCESS_DENIED.ordinal(), MessageSourceUtil.getMessage(ErrorCodeEnum.ACCESS_DENIED)))); + } }