Table of Contents
Profile Server 에 회원가입 API 추가
목표
Profile Server 에 회원가입 API 추가합니다.
build.gradle 수정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server:2.5.3'
implementation 'org.springframework.boot:spring-boot-starter-web:2.5.3'
implementation 'org.springframework.boot:spring-boot-starter-validation:2.5.3'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf:2.5.3'
implementation 'org.springframework.security:spring-security-jwt:1.1.1.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.5.3'
implementation 'commons-io:commons-io:20030203.000550'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
implementation 'org.passay:passay:1.6.1'
implementation 'com.google.guava:guava:30.1.1-jre'
// 버전을 명시적으로 지정해야 한다(?)
implementation 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.5.2'
runtimeOnly 'mysql:mysql-connector-java:8.0.25'
compileOnly 'org.projectlombok:lombok:1.18.20'
compileOnly 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
developmentOnly 'org.springframework.boot:spring-boot-devtools:2.5.3'
annotationProcessor 'org.projectlombok:lombok:1.18.20'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
testImplementation 'org.springframework.boot:spring-boot-starter-test:2.5.3'
}
Entity 레이어 추가
테이블 스키마를 수정합니다.
schema.sql
CREATE TABLE `tbl_user` (
`user_id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password` VARCHAR(100) NULL DEFAULT NULL,
`first_name` VARCHAR(100) NOT NULL,
`last_name` VARCHAR(100) NOT NULL,
`roles` VARCHAR(255) NULL DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE INDEX `UK_tbl_user_email` (`email`)
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
User.java
@Getter
@Setter
@Entity
@Table(name = "tbl_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long userId;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Column(nullable = false, length = 100)
private String firstName;
@Column(nullable = false, length = 100)
private String lastName;
@Column(nullable = false)
private String roles;
}
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Service 레이어 추가
UserService.java
@RequiredArgsConstructor
@Service
public class UserService {
private final UserMapper mapper = Mappers.getMapper(UserMapper.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
public void registerNewUserAccount(UserDto userDto) {
if (userRepository.findByEmail(userDto.getEmail()).isPresent()) {
throw new UserAlreadyExistException("이미 등록된 이메일입니다.");
}
User user = mapper.toEntity(userDto);
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRoles("USER");
userRepository.save(user);
}
}
Web 레이어 추가
UserInfoController.java
@RequiredArgsConstructor
@RestController
@RequestMapping("/api")
public class UserInfoController {
private final UserService userService;
// ......
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody @Valid UserDto userDto) {
GenericResponse response = new GenericResponse("", "");
userService.registerNewUserAccount(userDto);
return ResponseEntity.ok(response);
}
}
DTO 추가
Annotation 추가
ValidEmail.java
@Target({TYPE, FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = EmailValidator.class)
@Documented
public @interface ValidEmail {
String message() default "이메일 형식이 올바르지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
EmailValidator.java
public class EmailValidator implements ConstraintValidator<ValidEmail, String> {
private Pattern pattern;
private Matcher matcher;
private static final String EMAIL_PATTERN
= "^[_A-Za-z0-9-+]+(.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(.[A-Za-z0-9]+)*(.[A-Za-z]{2,})$";
@Override
public void initialize(ValidEmail constraintAnnotation) {
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context){
return (validateEmail(email));
}
private boolean validateEmail(String email) {
pattern = Pattern.compile(EMAIL_PATTERN);
matcher = pattern.matcher(email);
return matcher.matches();
}
}
PasswordMatches.java
@Target({TYPE,ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Documented
public @interface PasswordMatches {
String message() default "비밀번호가 일치하지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
PasswordMatchesValidator.java
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, Object> {
@Override
public void initialize(PasswordMatches constraintAnnotation) {
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context){
UserDto user = (UserDto) obj;
return user.getPassword().equals(user.getMatchingPassword());
}
}
DTO 클래스 추가
@PasswordMatches
@Getter
@Setter
public class UserDto {
@NotNull
@NotEmpty
private String firstName;
@NotNull
@NotEmpty
private String lastName;
@NotNull
@NotEmpty
// @ValidPassword
private String password;
private String matchingPassword;
@ValidEmail
@NotNull
@NotEmpty
private String email;
private String roles;
}
Exception 처리
UserAlreadyExistException.java
public final class UserAlreadyExistException extends RuntimeException {
private static final long serialVersionUID = 5861310537366287163L;
public UserAlreadyExistException() {
super();
}
public UserAlreadyExistException(final String message, final Throwable cause) {
super(message, cause);
}
public UserAlreadyExistException(final String message) {
super(message);
}
public UserAlreadyExistException(final Throwable cause) {
super(cause);
}
}
GenericResponse.java
public class GenericResponse {
private String message;
private String error;
public GenericResponse(final String message) {
super();
this.message = message;
}
public GenericResponse(final String message, final String error) {
super();
this.message = message;
this.error = error;
}
public GenericResponse(List<ObjectError> allErrors, String error) {
this.error = error;
String temp = allErrors.stream().map(e -> {
if (e instanceof FieldError) {
return "{\"field\":\"" + ((FieldError) e).getField() + "\",\"defaultMessage\":\"" + e.getDefaultMessage() + "\"}";
} else {
return "{\"object\":\"" + e.getObjectName() + "\",\"defaultMessage\":\"" + e.getDefaultMessage() + "\"}";
}
}).collect(Collectors.joining(","));
this.message = "[" + temp + "]";
}
public String getMessage() {
return message;
}
public void setMessage(final String message) {
this.message = message;
}
public String getError() {
return error;
}
public void setError(final String error) {
this.error = error;
}
}
RestResponseEntityExceptionHandler.java
@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
private final MessageSource messages;
public RestResponseEntityExceptionHandler(MessageSource messages) {
super();
this.messages = messages;
}
// API
// 400
@Override
protected ResponseEntity<Object> handleBindException(final BindException ex, final HttpHeaders headers, final HttpStatus status, final WebRequest request) {
// logger.error("400 Status Code", ex);
final BindingResult result = ex.getBindingResult();
final GenericResponse bodyOfResponse = new GenericResponse(result.getAllErrors(), "Invalid" + result.getObjectName());
return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(final MethodArgumentNotValidException ex, final HttpHeaders headers, final HttpStatus status, final WebRequest request) {
// logger.error("400 Status Code", ex);
final BindingResult result = ex.getBindingResult();
final GenericResponse bodyOfResponse = new GenericResponse(result.getAllErrors(), "Invalid" + result.getObjectName());
return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
@ExceptionHandler({ UserAlreadyExistException.class })
public ResponseEntity<Object> handleUserAlreadyExist(final RuntimeException ex, final WebRequest request) {
// logger.error("500 Status Code", ex);
final GenericResponse bodyOfResponse = new GenericResponse("[{\"object\":\"userDto\",\"defaultMessage\":\"이미 등록된 이메일입니다.\"}]", "InvaliduserDto");
return handleExceptionInternal(ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
@ExceptionHandler({ Exception.class })
public ResponseEntity<Object> handleInternal(final RuntimeException ex, final WebRequest request) {
logger.error("500 Status Code", ex);
final GenericResponse bodyOfResponse = new GenericResponse(messages.getMessage("message.error", null, request.getLocale()), "InternalError");
return new ResponseEntity<>(bodyOfResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
config 수정
클라이언트 서버에서의 접속을 허용하도록 개발용 설정을 추가해 줍니다.
DevConfig.java
@Configuration
@Profile("dev")
public class DevConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurerAdapter() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:8080");
}
};
}
}
/api/register
API 엔드 포인트를 추가해 줍니다.
ProfileServerConfig.java
@Configuration
@EnableResourceServer
public class ProfileServerConfig extends ResourceServerConfigurerAdapter {
// key-uri: http://auth.localhost:9000/oauth/token_key
// 위 설정으로 인해, JWT 토큰인것과, 암호화방식, 그리고 공개키까지 제공됩니다.
// 따라서, 추가설정 없이 암호화 JWT 토큰을 이용할 수 있습니다.
@Override
public void configure(HttpSecurity http) throws Exception {
http.headers().frameOptions().disable();
http
.authorizeRequests()
.antMatchers("/api/register").permitAll()
.and()
.authorizeRequests()
.antMatchers("/api/**").access("#oauth2.hasScope('profile')")
.and()
.authorizeRequests()
.antMatchers("/user/**", "/login").permitAll()
.and()
.authorizeRequests()
.anyRequest().authenticated();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Client 서버 수정
UserDto.java
@Getter
@Setter
public class UserDto {
private String firstName;
private String lastName;
private String password;
private String matchingPassword;
private String email;
private String roles;
}
@RequiredArgsConstructor
@Controller
@RequestMapping("/join")
public class JoinController {
private final String PROFILE_SERVER = "http://profile.localhost:9002/user/join";
private final HttpSession httpSession;
@GetMapping("/user")
public String join(Model model) {
UserDto userDto = new UserDto();
model.addAttribute("user", userDto);
return "join/user";
}
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="us">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script>
function jsCheckJoin() {
var frm = document.frm;
$.ajax({
url: "http://localhost:9002/api/register",
type: "post",
accept: "application/json",
contentType: "application/json; charset=utf-8",
data: JSON.stringify($("#frm").serializeObject()),
dataType: "json",
success: function(data) {
// success handle
alert('등록되었습니다.');
document.location.href = 'http://localhost:8080/oauth2/authorization/local';
},
error:function(request,status,error){
// alert("code:"+request.status);
//alert(request.responseText);
// alert("error:"+error);
var result = JSON.parse(request.responseText);
var errorList = JSON.parse(result.message);
//alert(result.message);
for (var i = 0; i < errorList.length; i++) {
//alert(errorList[i].object);
alert(errorList[i].defaultMessage);
}
//alert(errorList);
}
});
return false;
}
jQuery.fn.serializeObject = function() {
var obj = null;
try {
if (this[0].tagName && this[0].tagName.toUpperCase() === "FORM") {
var arr = this.serializeArray();
if (arr) {
obj = {};
jQuery.each(arr, function() {
obj[this.name] = this.value;
});
}//if ( arr ) {
}
} catch (e) {
alert(e.message);
} finally {
}
return obj;
};
</script>
</head>
<body>
<h1>회원가입</h1>
<form id="frm" name="frm" action="/join/user" th:object="${user}" method="POST" onsubmit="return jsCheckJoin()">
<div>
<label>이름</label>
<input th:field="*{firstName}"/>
<p th:each="error: ${#fields.errors('firstName')}"
th:text="${error}">Validation error</p>
</div>
<div>
<label>성</label>
<input th:field="*{lastName}"/>
<p th:each="error : ${#fields.errors('lastName')}"
th:text="${error}">Validation error</p>
</div>
<div>
<label>이메일</label>
<input type="email" th:field="*{email}"/>
<p th:each="error : ${#fields.errors('email')}"
th:text="${error}">Validation error</p>
</div>
<div>
<label>비밀번호</label>
<input type="password" th:field="*{password}"/>
<p th:each="error : ${#fields.errors('password')}"
th:text="${error}">Validation error</p>
</div>
<div>
<label>비밀번호 확인</label>
<input type="password" th:field="*{matchingPassword}"/>
</div>
<button type="submit">회원가입</button>
</form>
<a href="/login">로그인</a>
</body>
</html>
Pingback: Spring Boot Profile Server for Authorization Server – 상구리의 기술 블로그