diff --git a/.gitignore b/.gitignore index 3ef82de..adb97c2 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,10 @@ replay_pid* # this file should be generated by the helper script application.properties + +# debug configure file +config_debug_docker.json +config_debug.json + +# user configure file +config.json diff --git a/README-zh.md b/README-zh.md index 4593099..3e8e123 100644 --- a/README-zh.md +++ b/README-zh.md @@ -118,6 +118,12 @@ | `staticLocations` | `list` | `null` | 静态资源的路径,使用绝对路径,例如 `['/home/gcs/static']`。 | | `redisHost` | `string` | `"localhost"` | `Redis` 主机地址。 | | `redisPort` | `int` | `6379` | `Redis` 端口。 | +| `springMailDefaultEncoding` | `string` | `"UTF-8"` | `Spring` 邮件默认编码。 | +| `springMailProtocol` | `string` | `null` | `Spring` 邮件协议。 | +| `springMailHost` | `string` | `null` | `Spring` 邮件主机地址。 | +| `springMailPort` | `int` | `null` | `Spring` 邮件端口。 | +| `springMailUsername` | `string` | `null` | `Spring` 邮件用户名。 | +| `springMailPassword` | `string` | `null` | `Spring` 邮件密码。 | ## 手动部署 手动部署可以在任意的 `UNIX-like` 系统上面进行,下面依次介绍你需要手动完成的操作。 @@ -247,6 +253,18 @@ gitolite.admin.repository.path= spring.redis.host= # redis 端口 spring.redis.port= +# 邮件默认编码,通常为 UTF-8 +spring.mail.default-encoding= +# 邮件协议 +spring.mail.protocol= +# 邮件主机地址 +spring.mail.host= +# 邮件端口 +spring.mail.port= +# 邮件用户名 +spring.mail.username= +# 邮件密码 +spring.mail.password= ``` **注意**:所有的后端接口均是以 `gcs` 开头,所以在静态资源路径下面不应该有名为 `gcs` @@ -315,6 +333,14 @@ front-end.url= spring.mvc.static-path-pattern= spring.resources.static-locations= gitolite.admin.repository.path=/home/$USER/gitolite-admin +spring.redis.host=localhost +spring.redis.port=6379 +spring.mail.default-encoding=UTF-8 +spring.mail.protocol= +spring.mail.host= +spring.mail.port= +spring.mail.username= +spring.mail.password= ``` 其中 `$1` 会被替换成执行脚本时传入的第一个参数。脚本的使用方法为 @@ -322,7 +348,8 @@ gitolite.admin.repository.path=/home/$USER/gitolite-admin `postgres` 用户 (这里指数据库中的用户而不是 `OS` 中的用户) 的密码,在执行脚本之前确保当前用户可以使用 `sudo` 进行操作。 -在脚本执行成功后,开发者便可以通过 `mvn spring-boot:run` 启动程序,或者通过 `mvn test` 执行单元测试。 +在脚本执行成功后,开发者还需要填入用于测试的邮箱相关配置。 +完成后,开发者便可以通过 `mvn spring-boot:run` 启动程序,或者通过 `mvn test` 执行单元测试。 **注意**:有时在执行 `mvn spring-boot:run` 或者 `mvn test` 时会提示 `target` 目录的权限不够,这往往 是因为在使用 `bash prepare_dev.sh org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-mail + diff --git a/prepare_dev.sh b/prepare_dev.sh index b2dfc45..ad3121c 100644 --- a/prepare_dev.sh +++ b/prepare_dev.sh @@ -66,4 +66,10 @@ spring.resources.static-locations= gitolite.admin.repository.path=/home/$USER/gitolite-admin spring.redis.host=localhost spring.redis.port=6379 +spring.mail.default-encoding=UTF-8 +spring.mail.protocol= +spring.mail.host= +spring.mail.port= +spring.mail.username= +spring.mail.password= " > src/main/resources/application.properties diff --git a/script/deploy_helper.py b/script/deploy_helper.py index c97bca3..4b7e572 100644 --- a/script/deploy_helper.py +++ b/script/deploy_helper.py @@ -82,13 +82,14 @@ def parse_iterable_into_str(iterable, sep=" "): return sep.join(iterable).strip() -def write_content_to_file(content, file_path, mode = 'w'): +def write_content_to_file(content, file_path, mode='w'): try: with open(file_path, mode) as f: f.write(content) except Exception as e: command_checker(1, f"Error: {e}") + def create_systemd_service(config): assert(config != None) exec_start = parse_iterable_into_str([config.serviceStartJavaCommand] + @@ -134,7 +135,8 @@ def create_sys_v_init_service(config): ''' service_content = header + service_content log_debug(f"service_content:\n {service_content}") - write_content_to_file(service_content, f'{config.serviceSysVInitDirectory}/{config.serviceName}') + write_content_to_file( + service_content, f'{config.serviceSysVInitDirectory}/{config.serviceName}') res = os.system(f'chmod +x {config.serviceSysVInitDirectory}/{config.serviceName}') command_checker( res, f"Failed to chmod +x {config.serviceSysVInitDirectory}/{config.serviceName}") @@ -295,7 +297,7 @@ def init_database(config): config_datasource(config) -def create_or_update_user(username, password, homeDirectory = None): +def create_or_update_user(username, password, homeDirectory=None): if username == None or username == "": return if os.system(f"cat /etc/passwd | grep -w -E '^{username}'") != 0: @@ -307,7 +309,7 @@ def create_or_update_user(username, password, homeDirectory = None): res = os.system(command) message = message_tmp.format(command, res) command_checker(res, message) - elif homeDirectory is not None: # update the home directory + elif homeDirectory is not None: # update the home directory command = f'usermod -d {homeDirectory} {username}' res = os.system(command) message = message_tmp.format(command, res) @@ -339,6 +341,12 @@ def write_other_config(config): "staticLocations": "spring.web.resources.static-locations", "redisHost": "spring.redis.host", "redisPort": "spring.redis.port", + "springMailHost": "spring.mail.host", + "springMailPort": "spring.mail.port", + "springMailUsername": "spring.mail.username", + "springMailPassword": "spring.mail.password", + "springMailProtocol": "spring.mail.protocol", + "springMailDefaultEncoding": "spring.mail.default-encoding", } if config.frontEndUrl is None: config.frontEndUrl = "" @@ -411,9 +419,10 @@ def init_gitolite(config): log_debug(f"Create usr directory command: {command}") command_checker(os.system(command), f"Failed to create usr directory in gitolite-admin/conf") command = (f"su -c 'mkdir -p {config.serviceUserHomeDirectory}/gitolite-admin/conf/gitolite.d/repository' " - f"{config.serviceUser}") + f"{config.serviceUser}") log_debug(f"Create repository directory command: {command}") - command_checker(os.system(command), f"Failed to create repository directory in gitolite-admin/conf") + command_checker(os.system(command), + f"Failed to create repository directory in gitolite-admin/conf") content = f''' repo gitolite-admin RW+ = {config.serviceUser} @@ -425,15 +434,16 @@ def init_gitolite(config): repo @all_public_repo R = @all ''' - write_content_to_file(content, f'{config.serviceUserHomeDirectory}/gitolite-admin/conf/gitolite.conf') + write_content_to_file( + content, f'{config.serviceUserHomeDirectory}/gitolite-admin/conf/gitolite.conf') # create the usr directory in gitolite-admin/conf # configure the username and email for gitolite-admin command = (f"su -c 'git -C {config.serviceUserHomeDirectory}/gitolite-admin " - f"config user.name \"{config.adminName}\"' {config.serviceUser}") + f"config user.name \"{config.adminName}\"' {config.serviceUser}") log_debug(f"Config username command: {command}") command_checker(os.system(command), f"Failed to config username") command = (f"su -c 'git -C {config.serviceUserHomeDirectory}/gitolite-admin " - f"config user.email \"{config.adminEmail}\"' {config.serviceUser}") + f"config user.email \"{config.adminEmail}\"' {config.serviceUser}") log_debug(f"Config email command: {command}") command_checker(os.system(command), f"Failed to config email") command = (f"su -c \"git -C {config.serviceUserHomeDirectory}/gitolite-admin " @@ -445,6 +455,7 @@ def init_gitolite(config): log_debug(f"Push command: {command}") command_checker(os.system(command), f"Failed to push the change") + def install_redis(): # check if redis has been installed if os.system('command -v redis-cli') == 0: @@ -486,12 +497,14 @@ def deploy_on_ubuntu(config): if os.path.exists(f'{config.serviceUserHomeDirectory}/.ssh'): res = os.system(f'rm -rf {config.serviceUserHomeDirectory}/.ssh') command_checker(res, f"Failed to remove {config.serviceUserHomeDirectory}/.ssh") - res = os.system(f"su -c \"ssh-keygen -f {config.serviceUserHomeDirectory}/.ssh/id_rsa -N ''\" {config.serviceUser}") + res = os.system( + f"su -c \"ssh-keygen -f {config.serviceUserHomeDirectory}/.ssh/id_rsa -N ''\" {config.serviceUser}") command_checker(res, f"Failed to generate ssh key for {config.serviceUser}") init_gitolite(config) # let the service user can use git, rm and tee commands as the git user without password sudoers_entry = f"{config.serviceUser} ALL=({config.gitUserName}) NOPASSWD: /usr/bin/rm" - res = subprocess.run(f"echo '{sudoers_entry}' | tee /etc/sudoers.d/{config.serviceUser}", shell=True); + res = subprocess.run( + f"echo '{sudoers_entry}' | tee /etc/sudoers.d/{config.serviceUser}", shell=True) command_checker(res.returncode, f"Failed to create /etc/sudoers.d/{config.serviceUser}") res = subprocess.run(f"chmod 440 /etc/sudoers.d/{config.serviceUser}", shell=True) command_checker(res.returncode, f"Failed to chmod 440 /etc/sudoers.d/{config.serviceUser}") @@ -532,6 +545,7 @@ def delete_user(username): message = message_tmp.format(command, res) command_checker(res, message) + def clean(config): if config.deployWithDocker: res = os.system(f"docker stop {config.dockerName}") @@ -601,15 +615,17 @@ def get_cli_args(): deploy_helper.py [OPTION]... [--config-path PATH] [--distro DISTRO] or: deploy_helper.py [OPTION]... --clean or: deploy_helper.py [OPTION]... [--log-level LEVEL] [--in-docker]""" - ) + ) parser.add_argument('--config-path', nargs='?', default='../config.json', type=str, help="Default to '../config.json'. Path to config JSON file.") parser.add_argument('--distro', nargs='?', default='ubuntu', type=str, help="Default to 'ubuntu'. Set linux distribution.") parser.add_argument('--default-config-path', nargs='?', default='../config_default.json', type=str, help="Default to '../config_default.json'. Path to default config JSON file.") - parser.add_argument('--clean', action='store_true', help="Default to false. Clean up the project.") - parser.add_argument('--in-docker', action='store_true', help="Default to false. Whether or not deploy in docker.") + parser.add_argument('--clean', action='store_true', + help="Default to false. Clean up the project.") + parser.add_argument('--in-docker', action='store_true', + help="Default to false. Whether or not deploy in docker.") parser.add_argument('--log-level', nargs='?', default='INFO', type=str, help=("""\ Default to 'INFO'. @@ -625,8 +641,7 @@ def get_cli_args(): from working. The application is still running but encountered a failure. - CRITICAL: A severe error occurred, causing the application to stop or severely impact functionality. Immediate attention is required. -""" - )) +""")) return parser.parse_args() @@ -650,6 +665,15 @@ def deploy_with_docker(config): return +def check_args(config): + # TODO: add more checks + required_fields = ['springMailHost', 'springMailPort', + 'springMailUsername', 'springMailPassword'] + for field in required_fields: + if not hasattr(config, field): + raise ValueError(f"Missing required field: {field}") + + def main(): args = get_cli_args() if args.log_level.upper() not in logging._nameToLevel: @@ -661,7 +685,7 @@ def main(): setattr(config, 'configPath', args.config_path) setattr(config, 'defaultConfigPath', args.default_config_path) setattr(config, 'distro', args.distro) - # TODO: add args check + check_args(config) if args.clean: clean(config) elif not args.in_docker and config.deployWithDocker: diff --git a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java index c15e8fe..0d7f67e 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java @@ -12,6 +12,8 @@ public class ApiPathConstant { AUTHENTICATION_API_PREFIX + "/signout"; public static final String AUTHENTICATION_REFRESH_API_PATH = AUTHENTICATION_API_PREFIX + "/refresh"; + public static final String AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH = + AUTHENTICATION_API_PREFIX + "/send-email-verification-code"; public static final String DEVELOPMENT_API_PREFIX = ALL_API_PREFIX + "/developer"; public static final String DEVELOPMENT_GET_API_MAP_API_PATH = DEVELOPMENT_API_PREFIX + "/api"; @@ -30,6 +32,10 @@ public class ApiPathConstant { public static final String USER_DELETE_USER_API_PATH = USER_API_PREFIX + "/delete"; public static final String USER_PAGE_USER_REPOSITORY_API_PATH = USER_API_PREFIX + "/page/repository"; + public static final String USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH = + USER_API_PREFIX + "/update-password-with-old-password"; + public static final String USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH = + USER_API_PREFIX + "/update-password-with-email-verification-code"; public static final String REPOSITORY_API_PREFIX = ALL_API_PREFIX + "/repository"; public static final String REPOSITORY_CREATE_REPOSITORY_API_PATH = diff --git a/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java index 6b5df72..0935492 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ApplicationConstant.java @@ -6,4 +6,5 @@ public class ApplicationConstant { public static final String TEST_PROFILE = "test"; public static final long ACCESS_TOKEN_EXPIRATION = 10 * 60 * 1000L; // 10 minutes public static final long REFRESH_TOKEN_EXPIRATION = 30 * 24 * 60 * 60 * 1000L; // 30 days + public static final long EMAIL_VERIFICATION_CODE_EXPIRATION = 5 * 60 * 1000L; // 5 minutes } diff --git a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java index d9c178a..7c77520 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java @@ -21,4 +21,7 @@ public class ValidationConstant { public static final int MIN_SSH_KEY_PUBLICKEY_LENGTH = 1; public static final int MAX_SSH_KEY_PUBLICKEY_LENGTH = 4096; + + public static final int EMAIL_VERIFICATION_CODE_LENGTH = 6; + public static final String EMAIL_VERIFICATION_CODE_PATTERN = "^[0-9]*$"; } diff --git a/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java index d8ffc60..db139e1 100644 --- a/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java +++ b/src/main/java/edu/cmipt/gcs/controller/AuthenticationController.java @@ -3,18 +3,20 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.ApplicationConstant; import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; 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.UserSignInDTO; +import edu.cmipt.gcs.pojo.user.UserSignUpDTO; import edu.cmipt.gcs.pojo.user.UserVO; import edu.cmipt.gcs.service.UserService; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; import edu.cmipt.gcs.util.JwtUtil; import edu.cmipt.gcs.util.MD5Converter; -import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.util.MessageSourceUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,19 +28,25 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.mail.internet.MimeMessage; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; 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; -import java.util.List; - /** * AuthenticationController * @@ -49,6 +57,10 @@ @RestController @Tag(name = "Authentication", description = "Authentication APIs") public class AuthenticationController { + @Value("${spring.mail.username}") + private String fromEmail; + + @Autowired private JavaMailSender javaMailSender; @Autowired private UserService userService; @PostMapping(ApiPathConstant.AUTHENTICATION_SIGN_UP_API_PATH) @@ -64,7 +76,7 @@ public class AuthenticationController { content = @Content(schema = @Schema(implementation = ErrorVO.class))), @ApiResponse(responseCode = "500", description = "Internal server error") }) - public void signUp(@Validated(CreateGroup.class) @RequestBody UserDTO user) { + public void signUp(@Validated @RequestBody UserSignUpDTO user) { QueryWrapper wrapper = new QueryWrapper(); wrapper.eq("username", user.username()); if (userService.exists(wrapper)) { @@ -75,12 +87,58 @@ public void signUp(@Validated(CreateGroup.class) @RequestBody UserDTO user) { if (userService.exists(wrapper)) { throw new GenericException(ErrorCodeEnum.EMAIL_ALREADY_EXISTS, user.email()); } + if (!EmailVerificationCodeUtil.verifyVerificationCode( + user.email(), user.emailVerificationCode())) { + throw new GenericException( + ErrorCodeEnum.INVALID_EMAIL_VERIFICATION_CODE, user.emailVerificationCode()); + } boolean res = userService.save(new UserPO(user)); if (!res) { throw new GenericException(ErrorCodeEnum.USER_CREATE_FAILED, user); } } + @GetMapping(ApiPathConstant.AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH) + @Operation( + summary = "Send email verification code", + description = "Send email verification code to the given email", + tags = {"Authentication", "Get Method"}) + @Parameters({ + @Parameter( + name = "email", + description = "Email", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + @ApiResponse(responseCode = "200", description = "Email verification code sent successfully") + public void sendEmailVerificationCode( + @RequestParam("email") + @Email( + message = + "{Email.authenticationController#sendEmailVerificationCode.email}") + @NotBlank( + message = + "{NotBlank.authenticationController#sendEmailVerificationCode.email}") + String email) { + String code = EmailVerificationCodeUtil.generateVerificationCode(email); + MimeMessage message = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(fromEmail); + helper.setTo(email); + helper.setSubject(MessageSourceUtil.getMessage("EMAIL_VERIFICATION_CODE_SUBJECT")); + helper.setText( + MessageSourceUtil.getMessage( + "EMAIL_VERIFICATION_CODE_CONTENT", + code, + ApplicationConstant.EMAIL_VERIFICATION_CODE_EXPIRATION / 60000)); + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.SERVER_ERROR, e); + } + javaMailSender.send(message); + } + @PostMapping(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) @Operation( summary = "Sign in a user", @@ -115,8 +173,16 @@ public ResponseEntity signIn(@Validated @RequestBody UserSignInDTO user) description = "Sign out with the given token", tags = {"Authentication", "Delete Method"}) @ApiResponse(responseCode = "200", description = "User signed out successfully") - public void signOut(@RequestBody List tokenList) { - JwtUtil.blacklistToken(tokenList); + @Parameters({ + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)) + }) + public void signOut(@RequestParam("id") Long id) { + JwtUtil.blacklistToken(id); } @GetMapping(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) @@ -146,9 +212,7 @@ public void signOut(@RequestBody List tokenList) { @ApiResponse(responseCode = "500", description = "Internal server error") }) public ResponseEntity refreshToken( - @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, @RequestHeader(HeaderParameter.REFRESH_TOKEN) String refreshToken) { - JwtUtil.blacklistToken(accessToken); HttpHeaders headers = JwtUtil.generateHeaders(JwtUtil.getId(refreshToken), false); 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 a0f55cc..4abe187 100644 --- a/src/main/java/edu/cmipt/gcs/controller/UserController.java +++ b/src/main/java/edu/cmipt/gcs/controller/UserController.java @@ -1,6 +1,7 @@ package edu.cmipt.gcs.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import edu.cmipt.gcs.constant.ApiPathConstant; @@ -11,13 +12,14 @@ import edu.cmipt.gcs.pojo.error.ErrorVO; import edu.cmipt.gcs.pojo.repository.RepositoryPO; import edu.cmipt.gcs.pojo.repository.RepositoryVO; -import edu.cmipt.gcs.pojo.user.UserDTO; import edu.cmipt.gcs.pojo.user.UserPO; +import edu.cmipt.gcs.pojo.user.UserUpdateDTO; import edu.cmipt.gcs.pojo.user.UserVO; import edu.cmipt.gcs.service.RepositoryService; import edu.cmipt.gcs.service.UserService; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; import edu.cmipt.gcs.util.JwtUtil; -import edu.cmipt.gcs.validation.group.UpdateGroup; +import edu.cmipt.gcs.util.MD5Converter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -35,7 +37,6 @@ 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.DeleteMapping; @@ -136,28 +137,120 @@ public UserVO getUser( 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) { + public ResponseEntity updateUser(@Validated @RequestBody UserUpdateDTO 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; if (!userService.updateById(new UserPO(user))) { throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, user); } 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().body(userVO); + } + + @PostMapping(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + @Operation( + summary = "Update user password", + description = "Update user password", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User password updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User password update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "oldPassword", + description = "Old password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "newPassword", + description = "New password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + public void updateUserPasswordWithOldPassword( + @RequestParam("id") Long id, + @RequestParam("oldPassword") String oldPassword, + @RequestParam("newPassword") String newPassword) { + UpdateWrapper wrapper = new UpdateWrapper(); + wrapper.eq("id", id); + wrapper.eq("user_password", MD5Converter.convertToMD5(oldPassword)); + if (!userService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.WRONG_UPDATE_PASSWORD_INFORMATION); + } + checkPasswordValidity(newPassword); + wrapper.set("user_password", MD5Converter.convertToMD5(newPassword)); + if (!userService.update(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, newPassword); + } + JwtUtil.blacklistToken(id); + } + + @PostMapping(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + @Operation( + summary = "Update user password with email verification code", + description = "Update user password with email verification code", + tags = {"User", "Post Method"}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "User password updated successfully"), + @ApiResponse( + responseCode = "400", + description = "User password update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))) + }) + @Parameters({ + @Parameter( + name = "email", + description = "Email", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "emailVerificationCode", + description = "Email verification code", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "newPassword", + description = "New password", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = String.class)) + }) + public void updateUserPasswordWithEmailVerificationCode( + @RequestParam("email") String email, + @RequestParam("emailVerificationCode") String emailVerificationCode, + @RequestParam("newPassword") String newPassword) { + if (!EmailVerificationCodeUtil.verifyVerificationCode(email, emailVerificationCode)) { + throw new GenericException( + ErrorCodeEnum.INVALID_EMAIL_VERIFICATION_CODE, emailVerificationCode); + } + UpdateWrapper wrapper = new UpdateWrapper(); + wrapper.eq("email", email); + if (!userService.exists(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, email); + } + checkPasswordValidity(newPassword); + wrapper.set("user_password", MD5Converter.convertToMD5(newPassword)); + if (!userService.update(wrapper)) { + throw new GenericException(ErrorCodeEnum.USER_UPDATE_FAILED, email); } - return ResponseEntity.ok().headers(headers).body(userVO); + JwtUtil.blacklistToken(userService.getOne(wrapper).getId()); } @DeleteMapping(ApiPathConstant.USER_DELETE_USER_API_PATH) @@ -192,17 +285,14 @@ public ResponseEntity updateUser( description = "User not found", content = @Content(schema = @Schema(implementation = ErrorVO.class))) }) - public void deleteUser( - @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, - @RequestHeader(HeaderParameter.REFRESH_TOKEN) String refreshToken, - @RequestParam("id") Long id) { + public void deleteUser(@RequestParam("id") Long id) { if (userService.getById(id) == null) { throw new GenericException(ErrorCodeEnum.USER_NOT_FOUND, id); } if (!userService.removeById(id)) { throw new GenericException(ErrorCodeEnum.USER_DELETE_FAILED, id); } - JwtUtil.blacklistToken(accessToken, refreshToken); + JwtUtil.blacklistToken(id); } @GetMapping(ApiPathConstant.USER_PAGE_USER_REPOSITORY_API_PATH) diff --git a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java index 31d0962..f8348c3 100644 --- a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java +++ b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java @@ -21,6 +21,7 @@ public enum ErrorCodeEnum { USER_CREATE_FAILED("USER_CREATE_FAILED"), USER_UPDATE_FAILED("USER_UPDATE_FAILED"), USER_DELETE_FAILED("USER_DELETE_FAILED"), + WRONG_UPDATE_PASSWORD_INFORMATION("WRONG_UPDATE_PASSWORD_INFORMATION"), REPOSITORY_NOT_FOUND("REPOSITORY_NOT_FOUND"), REPOSITORY_ALREADY_EXISTS("REPOSITORY_ALREADY_EXISTS"), @@ -42,7 +43,9 @@ public enum ErrorCodeEnum { SERVER_ERROR("SERVER_ERROR"), - ILLOGICAL_OPERATION("ILLOGICAL_OPERATION"); + ILLOGICAL_OPERATION("ILLOGICAL_OPERATION"), + + INVALID_EMAIL_VERIFICATION_CODE("INVALID_EMAIL_VERIFICATION_CODE"); // code means the error code in the message.properties private String code; diff --git a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java index 0429aeb..90bfdb9 100644 --- a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java +++ b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java @@ -113,7 +113,11 @@ public BufferedReader getReader() { ApiPathConstant.USER_CHECK_EMAIL_VALIDITY_API_PATH, ApiPathConstant.USER_CHECK_USERNAME_VALIDITY_API_PATH, ApiPathConstant.USER_CHECK_USER_PASSWORD_VALIDITY_API_PATH, - ApiPathConstant.REPOSITORY_CHECK_REPOSITORY_NAME_VALIDITY_API_PATH); + ApiPathConstant.REPOSITORY_CHECK_REPOSITORY_NAME_VALIDITY_API_PATH, + ApiPathConstant.AUTHENTICATION_SEND_EMAIL_VERIFICATION_CODE_API_PATH, + ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH, + ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH); @Override protected void doFilterInternal( @@ -183,10 +187,6 @@ private void authorize(HttpServletRequest request, String accessToken, String re 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 (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"); diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java deleted file mode 100644 index 4f83fa3..0000000 --- a/src/main/java/edu/cmipt/gcs/pojo/user/UserDTO.java +++ /dev/null @@ -1,60 +0,0 @@ -package edu.cmipt.gcs.pojo.user; - -import edu.cmipt.gcs.constant.ValidationConstant; -import edu.cmipt.gcs.validation.group.CreateGroup; -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; - -/** - * User Data Transfer Object - * - * @author Kaiser - */ -@Schema(description = "User Data Transfer Object") -public record UserDTO( - @Schema(description = "User ID") - @Null(groups = CreateGroup.class) - @NotNull(groups = UpdateGroup.class) - // 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, UpdateGroup.class}, - min = ValidationConstant.MIN_USERNAME_LENGTH, - max = ValidationConstant.MAX_USERNAME_LENGTH) - @NotBlank(groups = {CreateGroup.class}) - @Pattern( - regexp = ValidationConstant.USERNAME_PATTERN, - groups = {CreateGroup.class, UpdateGroup.class}) - String username, - @Schema( - description = "Email", - requiredMode = Schema.RequiredMode.REQUIRED, - example = "admin@cmipt.edu") - @Email(groups = {CreateGroup.class, UpdateGroup.class}) - @NotBlank(groups = {CreateGroup.class}) - String email, - @Schema( - description = "User Password (Unencrypted)", - requiredMode = Schema.RequiredMode.REQUIRED, - example = "123456") - @Size( - groups = {CreateGroup.class, UpdateGroup.class}, - min = ValidationConstant.MIN_PASSWORD_LENGTH, - max = ValidationConstant.MAX_PASSWORD_LENGTH) - @NotBlank(groups = {CreateGroup.class}) - @Pattern( - regexp = ValidationConstant.PASSWORD_PATTERN, - groups = {CreateGroup.class, UpdateGroup.class}) - 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 3f6ac96..5dcd775 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) { - 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(UserSignUpDTO user) { + this.username = user.username(); + this.email = user.email(); + this.userPassword = MD5Converter.convertToMD5(user.userPassword()); + } + + public UserPO(UserUpdateDTO user) { + this.id = Long.parseLong(user.id()); + this.username = user.username(); } } diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserSignUpDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserSignUpDTO.java new file mode 100644 index 0000000..8edb1be --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserSignUpDTO.java @@ -0,0 +1,49 @@ +package edu.cmipt.gcs.pojo.user; + +import edu.cmipt.gcs.constant.ValidationConstant; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "User Sign Up Data Transfer Object") +public record UserSignUpDTO( + @Schema( + description = "Username", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin") + @Size( + min = ValidationConstant.MIN_USERNAME_LENGTH, + max = ValidationConstant.MAX_USERNAME_LENGTH) + @NotBlank + @Pattern(regexp = ValidationConstant.USERNAME_PATTERN) + String username, + @Schema( + description = "Email", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "admin@cmipt.edu") + @Email + @NotBlank + String email, + @Schema( + description = "User Password (Unencrypted)", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "123456") + @Size( + min = ValidationConstant.MIN_PASSWORD_LENGTH, + max = ValidationConstant.MAX_PASSWORD_LENGTH) + @NotBlank + @Pattern(regexp = ValidationConstant.PASSWORD_PATTERN) + String userPassword, + @Schema( + description = "Email Verification Code", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "123456") + @Size( + min = ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH, + max = ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH) + @Pattern(regexp = ValidationConstant.EMAIL_VERIFICATION_CODE_PATTERN) + String emailVerificationCode) {} diff --git a/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java b/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java new file mode 100644 index 0000000..df79fb8 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/user/UserUpdateDTO.java @@ -0,0 +1,31 @@ +package edu.cmipt.gcs.pojo.user; + +import edu.cmipt.gcs.constant.ValidationConstant; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * User Data Transfer Object + * + * @author Kaiser + */ +@Schema(description = "User Update Data Transfer Object") +public record UserUpdateDTO( + @Schema(description = "User ID") @NotNull + // 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( + min = ValidationConstant.MIN_USERNAME_LENGTH, + max = ValidationConstant.MAX_USERNAME_LENGTH) + @NotBlank + @Pattern(regexp = ValidationConstant.USERNAME_PATTERN) + String username) {} diff --git a/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java b/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java new file mode 100644 index 0000000..c53323a --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/util/EmailVerificationCodeUtil.java @@ -0,0 +1,35 @@ +package edu.cmipt.gcs.util; + +import edu.cmipt.gcs.constant.ApplicationConstant; +import edu.cmipt.gcs.constant.ValidationConstant; + +public class EmailVerificationCodeUtil { + public static String generateVerificationCode(String email) { + String code = + String.valueOf( + (int) + ((Math.random() * 9 + 1) + * Math.pow( + 10, + ValidationConstant.EMAIL_VERIFICATION_CODE_LENGTH + - 1))); + RedisUtil.set( + generateRedisKey(email), + code, + ApplicationConstant.EMAIL_VERIFICATION_CODE_EXPIRATION); + return code; + } + + public static boolean verifyVerificationCode(String email, String verificationCode) { + if (verificationCode == null + || !verificationCode.equals(RedisUtil.get(generateRedisKey(email)))) { + return false; + } + RedisUtil.del(generateRedisKey(email)); + return true; + } + + private static String generateRedisKey(String email) { + return "email#" + email; + } +} diff --git a/src/main/java/edu/cmipt/gcs/util/JwtUtil.java b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java index 0c51736..7fe0bbe 100644 --- a/src/main/java/edu/cmipt/gcs/util/JwtUtil.java +++ b/src/main/java/edu/cmipt/gcs/util/JwtUtil.java @@ -11,7 +11,6 @@ import org.springframework.http.HttpHeaders; import java.util.Date; -import java.util.List; import javax.crypto.SecretKey; @@ -48,10 +47,9 @@ public static String generateToken(long id, TokenTypeEnum tokenType) { .claim(TOKEN_TYPE_CLAIM, tokenType.name()) .signWith(SECRET_KEY) .compact(); - // we just need to store the token in redis, the value is not important RedisUtil.set( + generateRedisKey(id, tokenType), token, - "", (tokenType == TokenTypeEnum.ACCESS_TOKEN ? ApplicationConstant.ACCESS_TOKEN_EXPIRATION : ApplicationConstant.REFRESH_TOKEN_EXPIRATION)); @@ -63,7 +61,7 @@ public static String generateToken(String id, TokenTypeEnum tokenType) { } public static String getId(String token) { - if (!RedisUtil.hasKey(token)) { + if (!RedisUtil.hasKey(generateRedisKey(token))) { throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); } try { @@ -80,7 +78,7 @@ public static String getId(String token) { } public static TokenTypeEnum getTokenType(String token) { - if (!RedisUtil.hasKey(token)) { + if (!RedisUtil.hasKey(generateRedisKey(token))) { throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); } try { @@ -111,16 +109,37 @@ public static HttpHeaders generateHeaders(String id, boolean addRefreshToken) { } /** - * Add token to blacklist + * Add tokens of a user to blacklist * * @author Kaiser * @param tokens */ - public static void blacklistToken(String... tokens) { - RedisUtil.del(tokens); + public static void blacklistToken(Long id) { + RedisUtil.del(generateRedisKey(id, TokenTypeEnum.ACCESS_TOKEN)); + RedisUtil.del(generateRedisKey(id, TokenTypeEnum.REFRESH_TOKEN)); } - public static void blacklistToken(List tokenList) { - blacklistToken(tokenList.toArray(new String[0])); + public static void blacklistToken(String id) { + blacklistToken(Long.valueOf(id)); + } + + private static String generateRedisKey(Long id, TokenTypeEnum tokenType) { + return "id:tokenType#" + id + ":" + tokenType.name(); + } + + private static String generateRedisKey(String token) { + try { + var payload = + Jwts.parser() + .verifyWith(SECRET_KEY) + .build() + .parseSignedClaims(token) + .getPayload(); + Long id = payload.get(ID_CLAIM, Long.class); + String tokenType = payload.get(TOKEN_TYPE_CLAIM, String.class); + return "id:tokenType#" + id + ":" + tokenType; + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.INVALID_TOKEN, token); + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d0dac74..2e800ab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,7 +40,7 @@ spring: reset-enable: false allow: # empty means allow all messages: - basename: message/exception,message/validation + basename: message/exception,message/validation,message/message encoding: UTF-8 mybatis-plus: diff --git a/src/main/resources/message/exception.properties b/src/main/resources/message/exception.properties index 38fce00..0aa8a4e 100644 --- a/src/main/resources/message/exception.properties +++ b/src/main/resources/message/exception.properties @@ -12,6 +12,7 @@ USER_NOT_FOUND=User not found: {0} USER_CREATE_FAILED=User create failed: {0} USER_UPDATE_FAILED=User update failed: {0} USER_DELETE_FAILED=User delete failed: {0} +WRONG_UPDATE_PASSWORD_INFORMATION=Wrong old password REPOSITORY_NOT_FOUND=Repository not found: {0} REPOSITORY_ALREADY_EXISTS=Repository already exists: {0} @@ -34,3 +35,5 @@ OPERATION_NOT_IMPLEMENTED=Operation not implemented SERVER_ERROR=Internal server error, try again later ILLOGICAL_OPERATION=Illogical operation, please check + +INVALID_EMAIL_VERIFICATION_CODE=Invalid email verification code: {0} diff --git a/src/main/resources/message/exception_zh_CN.properties b/src/main/resources/message/exception_zh_CN.properties index be8318c..0b8c501 100644 --- a/src/main/resources/message/exception_zh_CN.properties +++ b/src/main/resources/message/exception_zh_CN.properties @@ -12,6 +12,7 @@ USER_NOT_FOUND=未找到用户:{0} USER_CREATE_FAILED=创建用户失败:{0} USER_UPDATE_FAILED=更新用户失败:{0} USER_DELETE_FAILED=删除用户失败:{0} +WRONG_UPDATE_PASSWORD_INFORMATION=原密码错误 REPOSITORY_NOT_FOUND=未找到仓库:{0} REPOSITORY_ALREADY_EXISTS=仓库已存在:{0} @@ -34,3 +35,5 @@ OPERATION_NOT_IMPLEMENTED=操作未实现 SERVER_ERROR=服务器错误,请稍后再试 ILLOGICAL_OPERATION=不合理的操作,请检查 + +INVALID_EMAIL_VERIFICATION_CODE=无效的邮箱验证码:{0} diff --git a/src/main/resources/message/message.properties b/src/main/resources/message/message.properties new file mode 100644 index 0000000..fced7e2 --- /dev/null +++ b/src/main/resources/message/message.properties @@ -0,0 +1,2 @@ +EMAIL_VERIFICATION_CODE_SUBJECT=Email Verification Code +EMAIL_VERIFICATION_CODE_CONTENT=Your email verification code is {0}, this code will expire in {1} minutes. diff --git a/src/main/resources/message/message_zh_CN.properties b/src/main/resources/message/message_zh_CN.properties new file mode 100644 index 0000000..ae5d3cb --- /dev/null +++ b/src/main/resources/message/message_zh_CN.properties @@ -0,0 +1,2 @@ +EMAIL_VERIFICATION_CODE_SUBJECT=邮箱验证码 +EMAIL_VERIFICATION_CODE_CONTENT=您的邮箱验证码是{0},此验证码将在{1}分钟后过期。 diff --git a/src/main/resources/message/validation.properties b/src/main/resources/message/validation.properties index 554f89a..01fb160 100644 --- a/src/main/resources/message/validation.properties +++ b/src/main/resources/message/validation.properties @@ -1,30 +1,34 @@ VALIDATION_ERROR=Validation error: {0} -# userDTO validation messages -Null.userDTO.id=User ID must be null when creating a new user -NotNull.userDTO.id=User ID cannot be null -Size.userDTO.username=Username must be between {2} and {1} characters -NotBlank.userDTO.username=Username cannot be blank -NotBlank.userDTO.email=Email cannot be blank -Email.userDTO.email=Email must be a valid email address -Size.userDTO.userPassword=Password must be between {2} and {1} characters -NotBlank.userDTO.userPassword=Password cannot be blank -Pattern.userDTO.username=Username can only be alphanumeric, hyphen or underline -Pattern.userDTO.userPassword=Password can only be alphanumeric, underline, hyphen, dot or at sign +# UserSignUpDTO validation messages +Size.userSignUpDTO.username=Username must be between {2} and {1} characters +NotBlank.userSignUpDTO.username=Username cannot be blank +Pattern.userSignUpDTO.username=Username can only be alphanumeric, hyphen or underline +NotBlank.userSignUpDTO.email=Email cannot be blank +Email.userSignUpDTO.email=Email must be a valid email address +Size.userSignUpDTO.userPassword=Password must be between {2} and {1} characters +NotBlank.userSignUpDTO.userPassword=Password cannot be blank +Pattern.userSignUpDTO.userPassword=Password can only be alphanumeric, underline, hyphen, dot or at sign +Size.userSignUpDTO.emailVerificationCode=Email verification code must be between {2} and {1} characters +Pattern.userSignUpDTO.emailVerificationCode=Email verification code can only be numeric + +# sendEmailVerificationCode validation messages +NotBlank.userController#sendEmailVerificationCode.email={NotBlank.userSignUpDTO.email} +Email.userController#sendEmailVerificationCode.email={Email.userSignUpDTO.email} # checkEmailValidity validation messages -Email.userController#checkEmailValidity.email={Email.userDTO.email} -NotBlank.userController#checkEmailValidity.email={NotBlank.userDTO.email} +Email.userController#checkEmailValidity.email={Email.userSignUpDTO.email} +NotBlank.userController#checkEmailValidity.email={NotBlank.userSignUpDTO.email} # checkUsernameValidity validation messages -NotBlank.userController#checkUsernameValidity.username={NotBlank.userDTO.username} +NotBlank.userController#checkUsernameValidity.username={NotBlank.userSignUpDTO.username} Size.userController#checkUsernameValidity.username=Username must be between {min} and {max} characters -Pattern.userController#checkUsernameValidity.username={Pattern.userDTO.username} +Pattern.userController#checkUsernameValidity.username={Pattern.userSignUpDTO.username} # checkPasswordValidity validation messages -NotBlank.userController#checkPasswordValidity.password={NotBlank.userDTO.userPassword} +NotBlank.userController#checkPasswordValidity.password={NotBlank.userSignUpDTO.userPassword} Size.userController#checkPasswordValidity.password=Password must be between {min} and {max} characters -Pattern.userController#checkPasswordValidity.password={Pattern.userDTO.userPassword} +Pattern.userController#checkPasswordValidity.password={Pattern.userSignUpDTO.userPassword} # UserSignInDTO validation messages NotBlank.UserSignInDTO.username=Username cannot be blank diff --git a/src/main/resources/message/validation_zh_CN.properties b/src/main/resources/message/validation_zh_CN.properties index 74f4c71..7ecc531 100644 --- a/src/main/resources/message/validation_zh_CN.properties +++ b/src/main/resources/message/validation_zh_CN.properties @@ -1,30 +1,34 @@ VALIDATION_ERROR=校验错误:{0} -# userDTO validation messages -Null.userDTO.id=在创建新用户时,用户ID必须为空 -NotNulluserDTO.id=用户ID不能为空 -Size.userDTO.username=用户名必须在{2}至{1}个字符之间 -NotBlank.userDTO.username=用户名不能为空 -NotBlank.userDTO.email=电子邮件不能为空 -Email.userDTO.email=电子邮件必须是有效的电子邮件地址 -Size.userDTO.userPassword=密码必须在{2}至{1}个字符之间 -NotBlank.userDTO.userPassword=密码不能为空 -Pattern.userDTO.username=用户名只能是字母、数字、连字符或下划线 -Pattern.userDTO.userPassword=密码只能是字母、数字、下划线、连字符、点或@符号 +# UserSignUpDTO validation messages +Size.UserSignUpDTO.username=用户名必须在{2}至{1}个字符之间 +NotBlank.UserSignUpDTO.username=用户名不能为空 +Pattern.UserSignUpDTO.username=用户名只能是字母、数字、连字符或下划线 +NotBlank.UserSignUpDTO.email=电子邮件不能为空 +Email.UserSignUpDTO.email=电子邮件必须是有效的电子邮件地址 +Size.UserSignUpDTO.userPassword=密码必须在{2}至{1}个字符之间 +NotBlank.UserSignUpDTO.userPassword=密码不能为空 +Pattern.UserSignUpDTO.userPassword=密码只能是字母、数字、下划线、连字符、点或@符号 +Size.UserSignUpDTO.emailVerificationCode=电子邮件验证码必须在{2}至{1}个字符之间 +Pattern.UserSignUpDTO.emailVerificationCode=电子邮件验证码只能是数字 + +# sendEmailVerificationCode validation messages +NotBlank.userController#sendEmailVerificationCode.email={NotBlank.UserSignUpDTO.email} +Email.userController#sendEmailVerificationCode.email={Email.UserSignUpDTO.email} # checkEmailValidity validation messages -Email.userController#checkEmailValidity.email={Email.userDTO.email} -NotBlank.userController#checkEmailValidity.email={NotBlank.userDTO.email} +Email.userController#checkEmailValidity.email={Email.userSignUpDTO.email} +NotBlank.userController#checkEmailValidity.email={NotBlank.userSignUpDTO.email} # checkUsernameValidity validation messages -NotBlank.userController#checkUsernameValidity.username={NotBlank.userDTO.username} +NotBlank.userController#checkUsernameValidity.username={NotBlank.userSignUpDTO.username} Size.userController#checkUsernameValidity.username=用户名必须在{min}至{max}个字符之间 -Pattern.userController#checkUsernameValidity.username={Pattern.userDTO.username} +Pattern.userController#checkUsernameValidity.username={Pattern.userSignUpDTO.username} # checkPasswordValidity validation messages -NotBlank.userController#checkPasswordValidity.password={NotBlank.userDTO.userPassword} +NotBlank.userController#checkPasswordValidity.password={NotBlank.userSignUpDTO.userPassword} Size.userController#checkPasswordValidity.password=密码必须在{min}至{max}个字符之间 -Pattern.userController#checkPasswordValidity.password={Pattern.userDTO.userPassword} +Pattern.userController#checkPasswordValidity.password={Pattern.userSignUpDTO.userPassword} # UserSignInDTO validation messages NotBlank.UserSignInDTO.username=用户名不能为空 diff --git a/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java index 7faa37b..31b9850 100644 --- a/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java +++ b/src/test/java/edu/cmipt/gcs/controller/AuthenticationControllerTest.java @@ -12,6 +12,7 @@ import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.constant.TestConstant; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; import edu.cmipt.gcs.util.MessageSourceUtil; import org.junit.jupiter.api.MethodOrderer; @@ -37,62 +38,6 @@ public class AuthenticationControllerTest { @Autowired private MockMvc mvc; - private static String userDTO = - """ - { - "username": "%s", - "email": "%s", - "userPassword": "%s" - } - """ - .formatted( - TestConstant.USERNAME, TestConstant.EMAIL, TestConstant.USER_PASSWORD); - private static String otherUserDTO = - """ - { - "username": "%s", - "email": "%s", - "userPassword": "%s" - } - """ - .formatted( - TestConstant.OTHER_USERNAME, - TestConstant.OTHER_EMAIL, - TestConstant.OTHER_USER_PASSWORD); - private static String userSignInDTO = - """ - { - "username": "%s", - "userPassword": "%s" - } - """ - .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); - public static String otherUserSignInDTO = - """ - { - "username": "%s", - "userPassword": "%s" - } - """ - .formatted(TestConstant.OTHER_USERNAME, TestConstant.OTHER_USER_PASSWORD); - - private static String invalidUserDTO = - """ - { - "username": "test", - "email": "invalid email address", - "userPassword": "123456" - } - """; - private static String invalidUserSignInDTO = - """ - { - "username": "%s", - "userPassword": "%s" - } - """ - .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD + "wrong"); - /** * Test sign in with invalid user information * @@ -103,15 +48,45 @@ public class AuthenticationControllerTest { @Test @Order(Ordered.HIGHEST_PRECEDENCE) public void testSignUpValid() throws Exception { + String userSignUpDTO = + """ + { + "username": "%s", + "email": "%s", + "userPassword": "%s", + "emailVerificationCode": "%s" + } + """ + .formatted( + TestConstant.USERNAME, + TestConstant.EMAIL, + TestConstant.USER_PASSWORD, + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.EMAIL)); + String otherUserSignUpDTO = + """ + { + "username": "%s", + "email": "%s", + "userPassword": "%s", + "emailVerificationCode": "%s" + } + """ + .formatted( + TestConstant.OTHER_USERNAME, + TestConstant.OTHER_EMAIL, + TestConstant.OTHER_USER_PASSWORD, + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.OTHER_EMAIL)); mvc.perform( post(ApiPathConstant.AUTHENTICATION_SIGN_UP_API_PATH) .contentType(MediaType.APPLICATION_JSON) - .content(userDTO)) + .content(userSignUpDTO)) .andExpect(status().isOk()); mvc.perform( post(ApiPathConstant.AUTHENTICATION_SIGN_UP_API_PATH) .contentType(MediaType.APPLICATION_JSON) - .content(otherUserDTO)) + .content(otherUserSignUpDTO)) .andExpect(status().isOk()); } @@ -126,6 +101,23 @@ public void testSignUpValid() throws Exception { @Test @Order(Ordered.HIGHEST_PRECEDENCE + 1) public void testSignInValid() throws Exception { + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + String otherUserSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.OTHER_USERNAME, TestConstant.OTHER_USER_PASSWORD); + var response = mvc.perform( post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) @@ -182,13 +174,21 @@ public void testSignInValid() throws Exception { public void testRefreshValid() throws Exception { mvc.perform( get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) - .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) .header(HeaderParameter.REFRESH_TOKEN, TestConstant.REFRESH_TOKEN)) .andExpectAll(status().isOk(), header().exists(HeaderParameter.ACCESS_TOKEN)); } @Test public void testSignInInvalid() throws Exception { + String invalidUserSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD + "wrong"); + mvc.perform( post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) .contentType(MediaType.APPLICATION_JSON) @@ -213,10 +213,18 @@ public void testSignInInvalid() throws Exception { @Test public void testSignUpInvalid() throws Exception { + String invalidUserSignUpDTO = + """ + { + "username": "test", + "email": "invalid email address", + "userPassword": "123456" + } + """; mvc.perform( post(ApiPathConstant.AUTHENTICATION_SIGN_UP_API_PATH) .contentType(MediaType.APPLICATION_JSON) - .content(invalidUserDTO)) + .content(invalidUserSignUpDTO)) .andExpectAll( status().isBadRequest(), jsonPath("$.code", is(ErrorCodeEnum.VALIDATION_ERROR.ordinal()))); @@ -227,7 +235,6 @@ public void testRefreshInvalid() throws Exception { String invalidToken = "This is an invalid token"; mvc.perform( get(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH) - .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) .header(HeaderParameter.REFRESH_TOKEN, invalidToken)) .andExpectAll( status().isUnauthorized(), diff --git a/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java index eb28162..2f9f924 100644 --- a/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java +++ b/src/test/java/edu/cmipt/gcs/controller/UserControllerTest.java @@ -5,7 +5,6 @@ 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; @@ -13,6 +12,7 @@ import edu.cmipt.gcs.constant.HeaderParameter; import edu.cmipt.gcs.constant.TestConstant; import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.util.EmailVerificationCodeUtil; import edu.cmipt.gcs.util.MessageSourceUtil; import org.junit.jupiter.api.MethodOrderer; @@ -144,44 +144,22 @@ public void testCheckUsernameValidityValid() throws Exception { @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); + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "username": "%s" + } + """ + .formatted(TestConstant.ID, TestConstant.USERNAME))) + .andExpectAll( + status().isOk(), + jsonPath("$.username", is(TestConstant.USERNAME)), + jsonPath("$.id").isString()); } @Test @@ -190,22 +168,15 @@ public void testUpdateUserInvalid() throws Exception { 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" + "username": "%s" } """ - .formatted( - otherID, - TestConstant.USERNAME, - TestConstant.EMAIL, - TestConstant.USER_PASSWORD))) + .formatted(otherID, TestConstant.USERNAME))) .andExpectAll( status().isForbidden(), content() @@ -222,6 +193,122 @@ public void testUpdateUserInvalid() throws Exception { ErrorCodeEnum.ACCESS_DENIED)))); } + @Test + public void testUpdateUserPasswordWithOldPasswordValid() throws Exception { + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + .param("id", TestConstant.ID) + .param("oldPassword", TestConstant.USER_PASSWORD) + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll(status().isOk()); + TestConstant.USER_PASSWORD += "new"; + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + // get the new tokens + var response = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userSignInDTO)) + .andReturn() + .getResponse(); + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + @Test + public void testUpdateUserPasswordWithOldPasswordInvalid() throws Exception { + mvc.perform( + post(ApiPathConstant.USER_UPDATE_USER_PASSWORD_WITH_OLD_PASSWORD_API_PATH) + .param("id", TestConstant.ID) + .param("oldPassword", TestConstant.USER_PASSWORD + "wrong") + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum + .WRONG_UPDATE_PASSWORD_INFORMATION + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .WRONG_UPDATE_PASSWORD_INFORMATION)))); + } + + @Test + public void testUpdateUserPasswordWithEmailVerificationCodeValid() throws Exception { + mvc.perform( + post(ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + .param("email", TestConstant.EMAIL) + .param( + "emailVerificationCode", + EmailVerificationCodeUtil.generateVerificationCode( + TestConstant.EMAIL)) + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll(status().isOk()); + TestConstant.USER_PASSWORD += "new"; + String userSignInDTO = + """ + { + "username": "%s", + "userPassword": "%s" + } + """ + .formatted(TestConstant.USERNAME, TestConstant.USER_PASSWORD); + // get the new tokens + var response = + mvc.perform( + post(ApiPathConstant.AUTHENTICATION_SIGN_IN_API_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(userSignInDTO)) + .andReturn() + .getResponse(); + TestConstant.ACCESS_TOKEN = response.getHeader(HeaderParameter.ACCESS_TOKEN); + TestConstant.REFRESH_TOKEN = response.getHeader(HeaderParameter.REFRESH_TOKEN); + } + + @Test + public void testUpdateUserPasswordWithEmailVerificationCodeInvalid() throws Exception { + mvc.perform( + post(ApiPathConstant + .USER_UPDATE_USER_PASSWORD_WITH_EMAIL_VERIFICATION_CODE_API_PATH) + .param("email", TestConstant.EMAIL) + .param("emailVerificationCode", "123456") + .param("newPassword", TestConstant.USER_PASSWORD + "new")) + .andExpectAll( + status().isBadRequest(), + content() + .json( + """ + { + "code": %d, + "message": "%s" + } + """ + .formatted( + ErrorCodeEnum + .INVALID_EMAIL_VERIFICATION_CODE + .ordinal(), + MessageSourceUtil.getMessage( + ErrorCodeEnum + .INVALID_EMAIL_VERIFICATION_CODE, + "123456")))); + } + @Test public void testDeleteUserInvalid() throws Exception { String otherID = "123";