궤도

[Spring] [면적면적[4]] 초기화면 API 만들기 근데 이제 JWT를 곁들인 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[4]] 초기화면 API 만들기 근데 이제 JWT를 곁들인

영이오 2021. 7. 22. 19:47

북적북적의 백엔드를 클론하고 있다.

 

Github

 

build.gradle

plugins {
	id 'org.springframework.boot' version '2.5.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	all {
		exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-log4j2'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'org.projectlombok:lombok:1.18.18'
	runtimeOnly 'mysql:mysql-connector-java'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

test {
	useJUnitPlatform()
}

 

 

 

src/main/resources/application.yml

secret:
  key: secretkeyformyunjukmyunjuk
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/db_name?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password: pwd
  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false
    show-sql: true
    hibernate:
      ddl-auto: none
    properties:
      hibernate.format_sql: true

요구사항 정리

 

회원가입

[Request] {email, nickname, password, POST}

1. 입력값 유효성 확인

2. 이메일 중복가입 확인

 

[Response] {HttpStatus.NO_CONTENT}

 

로그인

[Request] {email, password, POST}

1. 입력값 유효성 확인

2. 입력한 정보가 DB의 데이터와 일치하는지 확인

 

[Response] {accessToken, refreshToken, HttpStatus.OK}

 

비밀번호 찾기

[Request] {email, POST}

1. 입력값 유효성 확인

2. 입력한 정보가 DB의 데이터와 일치하는지 확인

 

[Response] {password, HttpStatus.OK}

 

비밀번호 찾기는 당장 하지 않을 것이다. 안할 수도 있고...왜냐면

기존에 유저가 저장했던 비밀번호가 아니라 새로운 비밀번호를 줘야하는데, 그럼 또 그 비밀번호를 수정할 수 있게도 해야 하니까 나중에 마이페이지 구현할 때 하려고 한다.


Spring Security + JWT

 

일단 더이상 보안에 소홀할 수 없으니 Srping Security와 JWT를 이용해 인증된 사용자를 만들어보자.

 

Spring Security + JWT의 전반적인 내용

Refresh Token

인증된 유저 사용방법

 

이 블로그들을 참고했다. 내글보다 저기 있는 글들 보는게 더 나을 수도 있겠다.

 

src/main/java/jpa/myunjuk/infra/jwt/JwtTokenProvider.java

package jpa.myunjuk.infra.jwt;

import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    @Value("${secret.key}")
    private String secretKey;

    private final long ACCESS_TOKEN_VALID_TIME = 30 * 60 * 1000L; //유효시간 30분
    private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 12 * 1000L; //유효시간 2주

    private final UserDetailsService userDetailsService;

    //객체 초기화. secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    //JWT access token 생성
    public String createAccessToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); //JWT payload에 저장되는 정보단위
        claims.put("roles", roles); //key - value 쌍으로 저장
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

    //JWT refresh token 생성
    public String createRefreshToken(String value){
        Claims claims = Jwts.claims();
        claims.put("value", value);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

    //JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    //토큰에서 회원 정보 얻어내기
    private String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    //Request의 Header에서 token 값 가져오기. "X-AUTH-TOKEN" : "TOKEN값"
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    //토큰의 유효성과 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date()); //만료일자
        } catch (Exception e) { //유효성
            return false;
        }
    }

    //refresh token 정보 얻어내기
    public Claims getClaimsFromJwtToken(String jwtToken) throws JwtException {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
    }
}

이건 JWT를 생성하고 검증하는 컴포넌트다.

 

    @Value("${secret.key}")
    private String secretKey;

    private final long ACCESS_TOKEN_VALID_TIME = 30 * 60 * 1000L; //유효시간 30분
    private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 12 * 1000L; //유효시간 2주

    private final UserDetailsService userDetailsService;

secretKey는 말그대로 비밀이니까 application.yml에 잘 숨겨서 깃이그노어 처리했다. 저 위에 있는 yml파일에 써있는 key도 현재 지금 내 파일에 써있는 키와 다른 키다.

 

암튼 access token과 refresh 토큰의 유효시간도 설정한다.

 

    //JWT access token 생성
    public String createAccessToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); //JWT payload에 저장되는 정보단위
        claims.put("roles", roles); //key - value 쌍으로 저장
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

access token을 생성하는 부분이다. 인자로 들어가는 userPk가 내 경우엔 email인데...엄밀히 말하면 이건 pk가 아니다. 근데 String 타입만 가능하길래 unique 옵션 걸어놓은 email을 쓰는 것이다.

 

    //JWT refresh token 생성
    public String createRefreshToken(String value){
        Claims claims = Jwts.claims();
        claims.put("value", value);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간
                .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME)) //토큰 만료 시간
                .signWith(SignatureAlgorithm.HS256, secretKey) //사용할 암호화 알고리즘
                .compact();
    }

refresh token을 생성하는 부분이다. 인자로 들어가는 value는 UUID 랜덤스트링을 변형한 값이 들어간다.

 

    //JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    //토큰에서 회원 정보 얻어내기
    private String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

getUserPk로 access token에서 pk(email)을 얻어내고 그 값을 기반으로 유저를 찾아내 인증 정보를 조회한다.

(사실 잘 이해 못함)

 

    //Request의 Header에서 token 값 가져오기. "X-AUTH-TOKEN" : "TOKEN값"
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

나중에 포스트맨에서 토큰을 통해 유저를 찾으려면 X-AUTH-TOKEN이라고 이름붙여서 가져오면 된다.

 

 

    //토큰의 유효성과 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date()); //만료일자
        } catch (Exception e) { //유효성
            return false;
        }
    }

토큰엔 유효기간이 있으니 토큰의 유효성도 확인해야 하고

 

    //refresh token 정보 얻어내기
    public Claims getClaimsFromJwtToken(String jwtToken) throws JwtException {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken).getBody();
    }

이건 getUserPk랑 비슷한 것이다. 그건 access token에서 유저를 찾아냈고 이건 refresh에서 찾아내고

(아닐 수도 있음)

 

src/main/java/jpa/myunjuk/infra/jwt/JwtAuthenticationFilter.java

package jpa.myunjuk.infra.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //헤더에서 JWT 받아오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        //유효한 토큰인지 확인
        if (token != null && jwtTokenProvider.validateToken(token)) {
            //토큰이 유효하면 토큰으로부터 유저 정보 반환
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            //SecurityContext에 Authentication 객체를 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

JwtProvider를 실제로 사용하면서 인증도 하고 유저정보도 받아오는 필터라고 하네요

 

src/main/java/jpa/myunjuk/infra/config/WebSecurityConfig.java

package jpa.myunjuk.infra.config;

import jpa.myunjuk.infra.jwt.JwtAuthenticationFilter;
import jpa.myunjuk.infra.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    //암호화에 필요한 PasswordEncoder를 Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    //authenticationManager를 Bean 등록
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception{
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable() //rest api만 고려하면 해제해도 되는듯?
                    .csrf().disable() //csrf 보안 토큰 diable 처리
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //토큰 기반 인증이라 세션은 사용하지 않음
                    .and()
                    .authorizeRequests() //요청에 대한 권한 체크
                    .antMatchers("/admin/**").hasRole("ADMIN")
                    .antMatchers("/user/**").hasRole("USER")
                    .antMatchers("/**").permitAll() //그외 나머지 요청은 누구나 접근 가능
                    .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                            UsernamePasswordAuthenticationFilter.class); //JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음
    }
}

최종적으로 만든 필터를 여기에 등록해야 쓸 수 있나보다.

 

    //JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

한편 앞서 JwtTokenProvider 파일에서 이런 메소드를 짰는데

 

src/main/java/jpa/myunjuk/module/service/CustomUserDetailService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.NoSuchDataException;
import jpa.myunjuk.module.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(() -> new NoSuchDataException("userId = " + username));
    }
}

이렇게 만들어줘야 한다.

근데 또 보면 findByEmail이 아직 없는데...

 

src/main/java/jpa/myunjuk/module/repository/UserRepository.java

package jpa.myunjuk.module.repository;

import jpa.myunjuk.module.model.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

스프링데이터 jpa를 활용해서 만든다.

 

근데 다시 또 저 위에 보면 NoSuchDataException이란게 있다. 저건 뭐냐면

 

src/main/java/jpa/myunjuk/infra/exception/NoSuchDataException.java

package jpa.myunjuk.infra.exception;

public class NoSuchDataException extends CustomRuntimeException{

    public NoSuchDataException(String msg){
        super(msg);
        name = "NoSuchDataException";
    }
}

내가 만든 익셉션이다.

 

src/main/java/jpa/myunjuk/infra/exception/CustomRuntimeException.java

package jpa.myunjuk.infra.exception;

import lombok.Getter;

public class CustomRuntimeException extends RuntimeException{

    @Getter
    String name;

    public CustomRuntimeException(String msg){
        super(msg);
    }
}

이 친구를 상속받은거고

 

src/main/java/jpa/myunjuk/infra/exception/GlobalExceptionHandler.java

package jpa.myunjuk.infra.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({
            NoSuchDataException.class})
    public ResponseEntity<?> handleRuntimeExceptions(final CustomRuntimeException e) {
        return ResponseEntity.badRequest().body(errorMsg(e.getName(), e.getMessage()));
    }

    private Map<String, String> errorMsg(String name, String msg) {
        Map<String, String> error = new HashMap<>();
        error.put(name, msg);
        return error;
    }
}

이렇게 핸들링한다.

 

참으로 멀리왔는데...

 

src/main/java/jpa/myunjuk/module/model/domain/User.java

package jpa.myunjuk.module.model.domain;

import com.sun.istack.NotNull;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User implements UserDetails {

    @Id
    @Column(name = "user_sn")
    @GeneratedValue
    private Long id;

    @Column(unique = true)
    @NotNull
    private String email;

    @Column(length = 300)
    @NotNull
    private String password;

    @NotNull
    private String nickname;

    @Column(name = "user_img")
    private String img;

    @Builder.Default
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Book> books = new ArrayList<>();

    @Builder.Default
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<UserCharacter> userCharacters = new ArrayList<>();

    @Setter
    private String refreshTokenValue;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

그래서 이것들을 사용하기 위해 User domian이 UserDetails를 implement한 것이다.

getUsername()으로 넘어가는게 email인게 맘에 들지 않지만...String이어야 하니 어쩔 수 없다.

 

그럼 이제 준비를 다 했으니 요구사항을 구현하도록 하자.

 

근데 이대로 프론트와 연결을 시도하면 cors에러가 뜰 것이다.

링크<- 해결한 글


회원가입

 

1. 회원가입시엔 이메일, 비밀번호, 닉네임이 필요하다.

2. 이미 가입된 이메일로 가입할 수 없다.

3. 회원가입을 하면 귀여운 기본 캐릭터를 하나 준다.(뭔지 궁금하면 어플 깔아보세요)

 

src/main/java/jpa/myunjuk/module/model/dto/UserDtos.java

package jpa.myunjuk.module.model.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;

public class UserDtos {

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @NotNull
    public static class UserSignUpReqDto {

        @Email
        private String email;
        private String password;
        private String nickname;
    }
}

회원가입할 사용자의 정보를 저장할 dto를 만들었다. 모두 @NotNull을 달아주고 email에는 추가로 @Email도 달아줬다.

그럼 얘네들에 대한 validation이 잘 작동하는지 확인해야 한다.

inner class로 만든 이유는 양이 점점...많아지기 때문인데 그래도 중복이나 그런게 많아서...나중에 리팩터링을 해야겠다.

 

src/main/java/jpa/myunjuk/infra/exception/GlobalExceptionHandler.java

package jpa.myunjuk.infra.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({
            NoSuchDataException.class})
    public ResponseEntity<?> handleRuntimeExceptions(final CustomRuntimeException e) {
        return ResponseEntity.badRequest().body(errorMsg(e.getName(), e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getAllErrors()
                .forEach(c -> errors.put(((FieldError) c).getField(), c.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

    private Map<String, String> errorMsg(String name, String msg) {
        Map<String, String> error = new HashMap<>();
        error.put(name, msg);
        return error;
    }
}

 

그전에 validation에 대한 익셉션을 처리해주자.

 

참고 블로그 여길 참고했다.

 

src/test/java/jpa/myunjuk/module/model/dto/UserSignUpReqDtoTest.java

package jpa.myunjuk.module.model.dto;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

import java.util.Set;

import static jpa.myunjuk.module.model.dto.UserDtos.*;
import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("User Sign-Up Validation Test")
class UserSignUpReqDtoTest {

    public static ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    public static Validator validator = factory.getValidator();

    @BeforeAll
    public static void init() {
        factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @Test
    @DisplayName("User Sign-Up Request | Fail : Email Null")
    void emptyEmail(){
        UserSignUpReqDto user = UserSignUpReqDto.builder()
                .password("1234")
                .nickname("hello")
                .build();
        Set<ConstraintViolation<UserSignUpReqDto>> violations = validator.validate(user);
        assertThat(violations).isEmpty();
    }

    @Test
    @DisplayName("User Sign-Up Request | Fail : Invalid Email Format")
    void invalidEmail(){
        UserSignUpReqDto user = UserSignUpReqDto.builder()
                .email("wrong_format")
                .password("1234")
                .nickname("hello")
                .build();
        Set<ConstraintViolation<UserSignUpReqDto>> violations = validator.validate(user);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("올바른 형식의 이메일 주소여야 합니다");
        });
    }

    @Test
    @DisplayName("User Sign-Up Request | Fail : Password Null")
    void emptyPassword(){
        UserSignUpReqDto user = UserSignUpReqDto.builder()
                .email("h@gmail.com")
                .nickname("hello")
                .build();
        Set<ConstraintViolation<UserSignUpReqDto>> violations = validator.validate(user);
        assertThat(violations).isEmpty();
    }

    @Test
    @DisplayName("User Sign-Up Request | Fail : Nickname Null")
    void emptyNickname(){
        UserSignUpReqDto user = UserSignUpReqDto.builder()
                .email("h@gmail.com")
                .password("1234")
                .build();
        Set<ConstraintViolation<UserSignUpReqDto>> violations = validator.validate(user);
        assertThat(violations).isEmpty();
    }
}

3개의 값에 대한 널체크와 email에 대한 체크 총 4개의 테스트이다.

대충 읽어보면 어떻게 작동하는 코드인지 알듯하니 설명은 생략한다.

 

 

그럼 이제 회원가입 코드를 작성하자

 

src/main/java/jpa/myunjuk/module/service/UserService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateUserException;
import jpa.myunjuk.infra.exception.InvalidReqParamException;
import jpa.myunjuk.infra.exception.NoSuchDataException;
import jpa.myunjuk.infra.jwt.JwtTokenProvider;
import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.domain.UserCharacter;
import jpa.myunjuk.module.model.dto.UserDtos;
import jpa.myunjuk.module.repository.CharactersRepository;
import jpa.myunjuk.module.repository.UserCharacterRepository;
import jpa.myunjuk.module.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

import static jpa.myunjuk.module.model.dto.JwtDtos.*;

@Service
@RequiredArgsConstructor
public class UserService {

    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
    private final UserCharacterRepository userCharacterRepository;
    private final CharactersRepository charactersRepository;

    /**
     * signUp
     *
     * @param userSignUpReqDto
     * @return User
     */
    public User signUp(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        checkDuplicateUser(userSignUpReqDto.getEmail()); //중복 가입 체크
        Characters characters = charactersRepository.findById(1L).orElseThrow(() -> new NoSuchDataException("Missing default character"));
        User user = userRepository.save(buildUserFromUserJoinDto(userSignUpReqDto));
        userCharacterRepository.save(UserCharacter.builder() //회원가입시 주어지는 기본캐릭터
                .user(user)
                .characters(characters)
                .achieve(LocalDate.now())
                .representation(true)
                .build());
        return user;
    }

    private void checkDuplicateUser(String email) {
        userRepository.findByEmail(email)
                .ifPresent(param -> {
                    throw new DuplicateUserException("email = " + email);
                });
    }

    private User buildUserFromUserJoinDto(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        return User.builder()
                .email(userSignUpReqDto.getEmail())
                .password(passwordEncoder.encode(userSignUpReqDto.getPassword()))
                .nickname(userSignUpReqDto.getNickname())
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
    }
}

하나하나 뜯어보자

 

    /**
     * signUp
     *
     * @param userSignUpReqDto
     * @return User
     */
    public User signUp(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        checkDuplicateUser(userSignUpReqDto.getEmail()); //중복 가입 체크
        Characters characters = charactersRepository.findById(1L).orElseThrow(() -> new NoSuchDataException("Missing default character"));
        User user = userRepository.save(buildUserFromUserJoinDto(userSignUpReqDto));
        userCharacterRepository.save(UserCharacter.builder() //회원가입시 주어지는 기본캐릭터
                .user(user)
                .characters(characters)
                .achieve(LocalDate.now())
                .representation(true)
                .build());
        return user;
    }

일단 입력받은 사용자의 이메일에 대해 중복 가입 여부를 체크한다.

 

    private void checkDuplicateUser(String email) {
        userRepository.findByEmail(email)
                .ifPresent(param -> {
                    throw new DuplicateUserException("email = " + email);
                });
    }

만약 존재하면 DuplicateUserException을 던지는데

 

src/main/java/jpa/myunjuk/infra/exception/DuplicateUserException.java

package jpa.myunjuk.infra.exception;

public class DuplicateUserException extends CustomRuntimeException{

    public DuplicateUserException(String msg){
        super(msg);
        name = "DuplicateUserException";
    }
}

그냥 아까처럼 이렇게 만들고

 

src/main/java/jpa/myunjuk/infra/exception/GlobalExceptionHandler.java

package jpa.myunjuk.infra.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({
            DuplicateUserException.class,
            NoSuchDataException.class})
    public ResponseEntity<?> handleRuntimeExceptions(final CustomRuntimeException e) {
        return ResponseEntity.badRequest().body(errorMsg(e.getName(), e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getAllErrors()
                .forEach(c -> errors.put(((FieldError) c).getField(), c.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

    private Map<String, String> errorMsg(String name, String msg) {
        Map<String, String> error = new HashMap<>();
        error.put(name, msg);
        return error;
    }
}

등록하면 된다.

 

다시 서비스로 돌아와서

    /**
     * signUp
     *
     * @param userSignUpReqDto
     * @return User
     */
    public User signUp(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        checkDuplicateUser(userSignUpReqDto.getEmail()); //중복 가입 체크
        Characters characters = charactersRepository.findById(1L).orElseThrow(() -> new NoSuchDataException("Missing default character"));
        User user = userRepository.save(buildUserFromUserJoinDto(userSignUpReqDto));
        userCharacterRepository.save(UserCharacter.builder() //회원가입시 주어지는 기본캐릭터
                .user(user)
                .characters(characters)
                .achieve(LocalDate.now())
                .representation(true)
                .build());
        return user;
    }

중복 가입이 아님을 확인하면 유저를 저장하기 전에 기본캐릭터부터 찾아본다.

만약 없어서 NoSuchDataException이 터지면 내가 데베에 캐릭터들을 넣지 않은 것이다.

그러고 나서 드디어 userRepository.save로 저장하는데

 

    private User buildUserFromUserJoinDto(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        return User.builder()
                .email(userSignUpReqDto.getEmail())
                .password(passwordEncoder.encode(userSignUpReqDto.getPassword()))
                .nickname(userSignUpReqDto.getNickname())
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
    }

비밀번호는 암호화하고, roles를 추가해서 저장한다.

 

        userCharacterRepository.save(UserCharacter.builder() //회원가입시 주어지는 기본캐릭터
                .user(user)
                .characters(characters)
                .achieve(LocalDate.now())
                .representation(true)
                .build());
        return user;

마지막으로 저장된 유저에 대해 기본캐릭터도 넣어준 다음 리턴하면 되는데 주의할 것이 있다.

난 User와 UserCharacter를 양방향 매핑했다. 그래서 UserCharacter에 user를 넣을 때 user에도 넣어줘야 하는데...

 

src/main/java/jpa/myunjuk/module/model/domain/UserCharacter.java

package jpa.myunjuk.module.model.domain;

import com.sun.istack.NotNull;
import jpa.myunjuk.infra.converter.BooleanToYNConverter;
import lombok.*;
import org.springframework.util.Assert;

import javax.persistence.*;
import java.time.LocalDate;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserCharacter {

    @Id
    @Column(name = "uc_sn")
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_sn")
    @NotNull
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "character_sn")
    @NotNull
    private Characters characters;

    @NotNull
    private LocalDate achieve;

    @Convert(converter = BooleanToYNConverter.class)
    @NotNull
    private boolean representation;

    @Builder
    public UserCharacter(Long id, User user, Characters characters, LocalDate achieve, boolean representation) {
        Assert.notNull(user, "User must not be null");
        Assert.notNull(characters, "Characters must not be null");
        Assert.notNull(achieve, "Achieve must not be null");

        this.id = id;
        this.user = user;
        this.characters = characters;
        this.achieve = achieve;
        this.representation = representation;
        if (!user.getUserCharacters().contains(this))
            user.getUserCharacters().add(this);
    }
}

그래서 UserCharacter 엔티티의 빌더 안에 이렇게 user->character 정보도 입력되도록 만들어 준다.

 

아무튼 이러면 끝인데, 마지막에 dto없이 user를 그대로 리턴하는게 찜찜하게 느껴질 수도 있다.

 

src/main/java/jpa/myunjuk/module/controller/UserController.java

package jpa.myunjuk.module.controller;

import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

import static jpa.myunjuk.module.model.dto.JwtDtos.*;
import static jpa.myunjuk.module.model.dto.UserDtos.*;

@Slf4j
@RequiredArgsConstructor
@RestController
public class UserController {

    private final UserService userService;

    /**
     * 회원가입
     * localhost:8080/sign-up
     *
     * @param userSignUpReqDto
     * @return ResponseEntity
     */
    @PostMapping("/sign-up")
    public ResponseEntity<?> join(@Valid @RequestBody UserSignUpReqDto userSignUpReqDto) {
        log.info("[Request] sign-up");
        userService.signUp(userSignUpReqDto);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}

어차피 response에 안들어가서 괜찮다.

 

그럼 테스트를 하자

 

src/test/java/jpa/myunjuk/module/service/UserServiceTest.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static jpa.myunjuk.module.model.dto.UserDtos.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@DisplayName("User Service Test")
@Transactional
class UserServiceTest {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private UserService userService;

    @Test
    @DisplayName("User Sign-Up | Success")
    void singUpSuccess() throws Exception {
        UserSignUpReqDto userSignUpReqDto = UserSignUpReqDto.builder()
                .email("new@new.com")
                .password("1234")
                .nickname("hello")
                .build();
        User user = userService.signUp(userSignUpReqDto);

        //양방향 제대로 됐는지 확인
        assertEquals(user.getUserCharacters().size(), 1);
        assertTrue(user.getUserCharacters().get(0).isRepresentation());
    }
}

굳이 설명할 필요는 없을 것 같다.

 

 

src/test/java/jpa/myunjuk/module/controller/UserControllerTest.java

package jpa.myunjuk.module.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
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 org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.Map;

import static jpa.myunjuk.module.model.dto.UserDtos.*;
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.status;

@Transactional
@SpringBootTest
@DisplayName("User Controller Test")
@AutoConfigureMockMvc(addFilters = false)
class UserControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    @DisplayName("User Sign-Up | Success")
    void signUpSuccess() throws Exception {
        UserSignUpReqDto userSignUpReqDto = UserSignUpReqDto.builder()
                .email("new@new.com")
                .password("1234")
                .nickname("hello")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignUpReqDto);
        mockMvc.perform(post("/sign-up")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isNoContent());
    }
}

컨트롤러에서도 테스트해보자

 

 

중복 유저를 테스트하기 전에...지난 며칠 시행착오를 겪으면서 깨달은 것인데 DB에 테스트 유저를 넣어야겠다 싶었다.

그래서 회원가입이 잘되는 것도 확인했으니

 

테스트 유저를 넣을 것이다.

 

예상한대로 잘 나왔다.

 

그럼 아까 그 컨트롤러 테스트에

    @Test
    @DisplayName("User Sign-Up | Fail : Duplicate User")
    void signUpFailDuplicate() throws Exception {
        UserSignUpReqDto userSignUpReqDto = UserSignUpReqDto.builder()
                .email("test@test.com")
                .password("1234")
                .nickname("hello")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignUpReqDto);

        Map<String, String> error = new HashMap<>();
        error.put("DuplicateUserException", "email = test@test.com");
        String response = objectMapper.writeValueAsString(error);
        mockMvc.perform(post("/sign-up")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

이걸 추가한다.

 

BadRequest가 뜨는지, 그리고 에러 메세지가 예상한바와 같은지 확인할 것이다.

 

이렇게 회원가입이 끝났다.


로그인

 

로그인을 할 때 중요한 것은 다음과 같다.

1. 이메일/비밀번호 일치

2. Access token과 refresh token 발행

 

먼저 dto를 만들면

 

src/main/java/jpa/myunjuk/module/model/dto/UserDtos.java

package jpa.myunjuk.module.model.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;

public class UserDtos {

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @NotNull
    public static class UserSignUpReqDto {

        @Email
        private String email;
        private String password;
        private String nickname;
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @NotNull
    public static class UserSignInReqDto {

        @Email
        private String email;
        private String password;
    }
}

거의 중복이라 테스트는 생략한다.

 

src/main/java/jpa/myunjuk/module/service/UserService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateUserException;
import jpa.myunjuk.infra.exception.InvalidReqParamException;
import jpa.myunjuk.infra.exception.NoSuchDataException;
import jpa.myunjuk.infra.jwt.JwtTokenProvider;
import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.domain.UserCharacter;
import jpa.myunjuk.module.model.dto.UserDtos;
import jpa.myunjuk.module.repository.CharactersRepository;
import jpa.myunjuk.module.repository.UserCharacterRepository;
import jpa.myunjuk.module.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

import static jpa.myunjuk.module.model.dto.JwtDtos.*;

@Service
@RequiredArgsConstructor
public class UserService {

    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;
    private final UserCharacterRepository userCharacterRepository;
    private final CharactersRepository charactersRepository;

    /**
     * signUp
     *
     * @param userSignUpReqDto
     * @return User
     */
    public User signUp(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        checkDuplicateUser(userSignUpReqDto.getEmail()); //중복 가입 체크
        Characters characters = charactersRepository.findById(1L).orElseThrow(() -> new NoSuchDataException("Missing default character"));
        User user = userRepository.save(buildUserFromUserJoinDto(userSignUpReqDto));
        userCharacterRepository.save(UserCharacter.builder() //회원가입시 주어지는 기본캐릭터
                .user(user)
                .characters(characters)
                .achieve(LocalDate.now())
                .representation(true)
                .build());
        return user;
    }

    private void checkDuplicateUser(String email) {
        userRepository.findByEmail(email)
                .ifPresent(param -> {
                    throw new DuplicateUserException("email = " + email);
                });
    }

    /**
     * signIn
     *
     * @param userSignInReqDto
     * @return JwtDto
     */
    public JwtDto signIn(UserDtos.UserSignInReqDto userSignInReqDto) {
        User user = userRepository.findByEmail(userSignInReqDto.getEmail())
                .orElseThrow(() -> new NoSuchDataException("email = " + userSignInReqDto.getEmail()));
        if (!passwordEncoder.matches(userSignInReqDto.getPassword(), user.getPassword()))
            throw new NoSuchDataException("password = " + userSignInReqDto.getPassword());
        String[] jwtTokens = createJwtTokens(user, user.getRoles());
        return buildJwtDto(user, jwtTokens);
    }

    private String[] createJwtTokens(User user, List<String> roles) {
        String accessToken = jwtTokenProvider.createAccessToken(user.getUsername(), roles);
        String refreshTokenValue = UUID.randomUUID().toString().replace("-", "");
        saveRefreshTokenValue(user, refreshTokenValue);
        String refreshToken = jwtTokenProvider.createRefreshToken(refreshTokenValue);
        return new String[]{accessToken, refreshToken};
    }

    private void saveRefreshTokenValue(User user, String refreshToken) { //사용자의 refreshToken 데베에 저장
        user.setRefreshTokenValue(refreshToken);
        userRepository.save(user);
    }

    /**
     * refreshToken
     *
     * @param jwtRefreshReqDto
     * @return JwtDto
     */
    public JwtDto refreshUserTokens(JwtRefreshReqDto jwtRefreshReqDto) {
        User user = userRepository.findByEmail(jwtRefreshReqDto.getEmail())
                .orElseThrow(() -> new NoSuchDataException("email = " + jwtRefreshReqDto.getEmail()));
        checkIfRefreshTokenValid(user.getRefreshTokenValue(), jwtRefreshReqDto.getRefreshToken()); //유저의 실제 토큰과 클라이언트에서 넘어온 토큰
        String[] jwtTokens = createJwtTokens(user, user.getRoles());
        return buildJwtDto(user, jwtTokens);
    }

    private void checkIfRefreshTokenValid(String requiredValue, String givenRefreshToken) { //유효한지 확인
        String givenValue = String.valueOf(jwtTokenProvider.getClaimsFromJwtToken(givenRefreshToken).get("value"));
        if (!givenValue.equals(requiredValue)) {
            throw new InvalidReqParamException("Invalid refreshToken");
        }
    }

    private User buildUserFromUserJoinDto(UserDtos.UserSignUpReqDto userSignUpReqDto) {
        return User.builder()
                .email(userSignUpReqDto.getEmail())
                .password(passwordEncoder.encode(userSignUpReqDto.getPassword()))
                .nickname(userSignUpReqDto.getNickname())
                .roles(Collections.singletonList("ROLE_USER"))
                .build();
    }

    private JwtDto buildJwtDto(User user, String[] jwtTokens) {
        return JwtDto.builder()
                .userId(user.getId())
                .accessToken(jwtTokens[0])
                .refreshToken(jwtTokens[1])
                .build();
    }
}

그리고 이번엔 로그인이랑 같이 토큰을 재발행 해주는 api도 같이 만든다.

 

    /**
     * signIn
     *
     * @param userSignInReqDto
     * @return JwtDto
     */
    public JwtDto signIn(UserDtos.UserSignInReqDto userSignInReqDto) {
        User user = userRepository.findByEmail(userSignInReqDto.getEmail())
                .orElseThrow(() -> new NoSuchDataException("email = " + userSignInReqDto.getEmail()));
        if (!passwordEncoder.matches(userSignInReqDto.getPassword(), user.getPassword()))
            throw new NoSuchDataException("password = " + userSignInReqDto.getPassword());
        String[] jwtTokens = createJwtTokens(user, user.getRoles());
        return buildJwtDto(user, jwtTokens);
    }

먼저 이메일에 대한 유효성 체크를 하고, 비밀번호에 대한 유효성 체크를 한다.

그리고 나서 createJwtTokens 메소드로 토큰을 발행한다.

 

    private String[] createJwtTokens(User user, List<String> roles) {
        String accessToken = jwtTokenProvider.createAccessToken(user.getUsername(), roles);
        String refreshTokenValue = UUID.randomUUID().toString().replace("-", "");
        saveRefreshTokenValue(user, refreshTokenValue);
        String refreshToken = jwtTokenProvider.createRefreshToken(refreshTokenValue);
        return new String[]{accessToken, refreshToken};
    }

    private void saveRefreshTokenValue(User user, String refreshToken) { //사용자의 refreshToken 데베에 저장
        user.setRefreshTokenValue(refreshToken);
        userRepository.save(user);
    }

access token은 jwtTokenProvider가 주는걸 받아오고, refresh token은 UUID 랜덤 스트링을 만들어서 역시나 jwtTokenProvider가 주는걸 받아온다.

 

    private JwtDto buildJwtDto(User user, String[] jwtTokens) {
        return JwtDto.builder()
                .userId(user.getId())
                .accessToken(jwtTokens[0])
                .refreshToken(jwtTokens[1])
                .build();
    }

토큰들은 사용자 정보와 함께 dto로 담는데

 

src/main/java/jpa/myunjuk/module/model/dto/JwtDtos.java

package jpa.myunjuk.module.model.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotNull;

public class JwtDtos {

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @NotNull
    public static class JwtRefreshReqDto {

        private String email;
        private String refreshToken;
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class JwtDto {

        private Long userId;
        private String accessToken;
        private String refreshToken;
    }
}

이렇게 생겼다. JwtRefreshReqDto는 refreshTokens가 쓸 것인데 이건 로그인이랑 유사하니 설명하지 않는다.

 

src/main/java/jpa/myunjuk/module/controller/UserController.java

package jpa.myunjuk.module.controller;

import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

import static jpa.myunjuk.module.model.dto.JwtDtos.*;
import static jpa.myunjuk.module.model.dto.UserDtos.*;

@Slf4j
@RequiredArgsConstructor
@RestController
public class UserController {

    private final UserService userService;

    /**
     * 로그인
     * localhost:8080/sign-in
     *
     * @param userSignInReqDto
     * @return ResponseEntity
     */
    @PostMapping("/sign-in")
    public ResponseEntity<?> signIn(@Valid @RequestBody UserSignInReqDto userSignInReqDto) {
        log.info("[Request] sign-in " + userSignInReqDto.toString());
        return new ResponseEntity<>(userService.signIn(userSignInReqDto), HttpStatus.OK);
    }

    /**
     * 토큰 재발행
     * localhost:8080/refresh-tokens
     *
     * @param jwtRefreshReqDto
     * @return ResponseEntity
     */
    @PostMapping("/refresh-tokens")
    public ResponseEntity<?> refreshUserToken(@Valid @RequestBody JwtRefreshReqDto jwtRefreshReqDto) {
        log.info("[Request] refresh-tokens");
        return new ResponseEntity<>(userService.refreshUserTokens(jwtRefreshReqDto), HttpStatus.OK);
    }
}

컨트롤러에 올려주고

 

src/test/java/jpa/myunjuk/module/controller/UserControllerTest.java

package jpa.myunjuk.module.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
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 org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.Map;

import static jpa.myunjuk.module.model.dto.UserDtos.*;
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.status;

@Transactional
@SpringBootTest
@DisplayName("User Controller Test")
@AutoConfigureMockMvc(addFilters = false)
class UserControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    @DisplayName("User Sign-Up | Success")
    void signUpSuccess() throws Exception {
        UserSignUpReqDto userSignUpReqDto = UserSignUpReqDto.builder()
                .email("new@new.com")
                .password("1234")
                .nickname("hello")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignUpReqDto);
        mockMvc.perform(post("/sign-up")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isNoContent());
    }

    @Test
    @DisplayName("User Sign-Up | Fail : Duplicate User")
    void signUpFailDuplicate() throws Exception {
        UserSignUpReqDto userSignUpReqDto = UserSignUpReqDto.builder()
                .email("test@test.com")
                .password("1234")
                .nickname("hello")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignUpReqDto);

        Map<String, String> error = new HashMap<>();
        error.put("DuplicateUserException", "email = test@test.com");
        String response = objectMapper.writeValueAsString(error);
        mockMvc.perform(post("/sign-up")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

    @Test
    @DisplayName("User Sign-In | Success")
    void signInSuccess() throws Exception {
        UserSignInReqDto userSignInReqDto = UserSignInReqDto.builder()
                .email("test@test.com")
                .password("1234")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignInReqDto);
        mockMvc.perform(post("/sign-in")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("User Sign-In | Fail : Wrong Email")
    void singInFailEmail() throws Exception {
        UserSignInReqDto userSignInReqDto = UserSignInReqDto.builder()
                .email("wrong@test.com")
                .password("1234")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignInReqDto);

        Map<String, String> error = new HashMap<>();
        error.put("NoSuchDataException", "email = wrong@test.com");
        String response = objectMapper.writeValueAsString(error);
        mockMvc.perform(post("/sign-in")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

    @Test
    @DisplayName("User Sign-In | Fail : Wrong Password")
    void singInFailPassword() throws Exception {
        UserSignInReqDto userSignInReqDto = UserSignInReqDto.builder()
                .email("test@test.com")
                .password("wrong")
                .build();
        String jsonString = objectMapper.writeValueAsString(userSignInReqDto);

        Map<String, String> error = new HashMap<>();
        error.put("NoSuchDataException", "password = wrong");
        String response = objectMapper.writeValueAsString(error);
        mockMvc.perform(post("/sign-in")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonString))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }
}

로그인 테스트를 추가한다.

 

 

Comments