Profile Server 에 회원가입 API 추가

By | 2021년 8월 1일
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>

One thought on “Profile Server 에 회원가입 API 추가

  1. Pingback: Spring Boot Profile Server for Authorization Server – 상구리의 기술 블로그

답글 남기기