[SpringBoot(4)] 스프링 시큐리티 - 암호화, JWT, TDD

배지원·2022년 12월 8일
0

실습

목록 보기
22/24

유저가 회원가입 데이터를 입력하면 중복검사를 통해 중복이 아니면 DB에 저장되어 회원가입이 성공되고 중복이라면 예외처리하여 에러문을 반환해주는 코드를 작성했었다.
하지만 데이터를 저장할때 비밀번호 같은 경우는 외부에 노출이 되면 안되기 때문에 암호화 작업이 필요하다 이때, 필요한 것이 Spring Security이다.

파일 구조

Spring Security

  • 스프링 시큐리티는 스프링 기반의 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크이다. 스큐리티는 인증(Authenticate, 로그인)과 인가(Authorize, 권한부여)의 기능을 처리한다.

인증 VS 인가

  • 인증은 사용자가 누구인지 확인하는 단계를 말한다. 대표적인 예로는 로그인이 있으며 DB에 저장되어 있는 데이터와 유저가 입력한 데이터를 비교하여 일치 여부를 확인한다. 만약 일치한다면 토큰을 발행해주고 일치하지 않다면 예외처리를 던져준다.

  • 인가는 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정을 의미한다. 대표적인 예로는 공지사항 같은 관리자만 입력할 수 있는 페이지에서는 일반 유저에게 글작성 권한을 주지 않는 것이다.

1. 동작구조

기존 SpringBoot 통신 동작 구조

SpringSecurity 통신 동작 구조

이와 같이 SpringSecurity는 기존의 방식에 필터체인이 추가가 되었는데 필터 체인은 서블릿 컨테이너에서 관리하는 ApplicationFilterChani을 의미한다. 즉, 필터체인에서 인증, 인가를 허가해준다.

클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URL을 확인해서 필터와 서블릿을 매핑해준다.


2. 기본 설정

Spring Security를 사용하기 위해서는 Gradle에 해당 dependencies를 추가해줘야 한다.
Intellij에서 직접 찾아서 추가해줘도 되고 mvnrepository 홈페이지를 통해 추가해줘도 된다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.7.5'

3. CODE

Spring Security Config

(1) SecurityConfig

@EnableWebSecurity  // 스프링 시큐리티를 활성화하는 어노테이션
@Configuration      // 스프링의 기본 설정 정보들의 환경 세팅을 돕는 어노테이션
// @EnableGlobalMethodSecurity(prePostEnabled = true)  // Controller에서 특정 페이지에 권한이 있는 유저만 접근을 허용할 경우
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity             // SecurityFilterChain에서 요청에 접근할 수 있어서 인증, 인가 서비스에 사용
                .httpBasic().disable()      // http basic auth 기반으로 로그인 인증창이 뜬다. 기본 인증을 이용하지 않으려면 .disable()을 추가해준다.
                .csrf().disable()       // csrf, api server이용시 .disable (html tag를 통한 공격)
                .cors()	 //  다른 도메인의 리소스에 대해 접근이 허용되는지 체크
                .and()   // 묶음 구분(httpBasic(),crsf,cors가 한묶음)
                .authorizeRequests()    // 각 경로 path별 권한 처리
                .antMatchers("/api/**").permitAll()         // 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.
                .antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll()
                .and()
                .sessionManagement()        // 세션 관리 기능을 작동한다.      .maximunSessions(숫자)로 최대 허용가능 세션 수를 정할수 있다.(-1로 하면 무제한 허용)
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀(STATELESS는 인증 정보를 서버에 담지 않는다.)
                .and()
             //   .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                //UserNamePasswordAuthenticationFilter적용하기 전에 JWTTokenFilter를 적용 하라는 뜻 입니다.
                .build();
    }
}
  • SecurityFilterChain 기본 설정파일이다.
  • 이전에는 WebSecurityConfigurerAdapter를 상속받아 사용했지만 이제는 Bean폴더를 만들어서 사용함
  • @EnableWebSecurity를 통해 스프링 시큐리티를 활성화한다.
  • Configuration, @Bean을 사용하여 Bean폴더로 만든다.
  • 위에 말했던 것처럼 필터체인을 통해 인증, 인가를 해준다고 했는데 그때 필터체인을 설정하는 공간으로 필터체인 클래스를 재정의하여 사용한다.

    httpSecurity : SecurityFilterChain에서 요청에 접근할 수 있어서 인증, 인가 서비스에 사용
    httpBasic( ) : http basic auth 기반으로 로그인 인증창이 뜬다. 기본 인증을 이용하지 않으려면 .disable()을 추가해준다.
    csrf( ) : csrf, api server이용시 .disable (html tag를 통한 공격)
    cors( ) : 다른 도메인의 리소스에 대해 접근이 허용되는지 체크


    authorizeRequests( ) : 각 경로 path별 권한 처리
    antMatchers(경로) : 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.


    sessionManagement( ) : 세션 관리 기능을 작동한다.
    sessionCreationPolicy(SessionCreationPolicy.STATELESS) : jwt사용하는 경우 씀(STATELESS는 인증 정보를 서버에 담지 않는다.)


    and( ) : 세션 구분을 한다(이전 세션이 끝났으면 새로 and를 통해 새로운 세션을 추가함)

(2) BCryptPasswordEncoder

@Configuration
public class EncrypterConfig {
    @Bean
    public BCryptPasswordEncoder encodePwd(){
        return new BCryptPasswordEncoder();     // password를 인코딩 해줄때 쓰기 위함
    }
}
  • 스프링 시큐리티 프레임워크에서 제공하는 클래스 중 하나로 비밀번호를 암호화하는데 사용할 수 있는 메서드를 가진 클래스이다.
  • 회원가입을 할때 유저가 입력하 비밀번호를 DB에 저장할때 그대로 저장하면 해킹하는데 위험이 크기 때문에 암호화 과정을 거쳐 저장하면 데이터가 노출되더라도 확인하는데 어려움이 있다.

Domain

  • 이전 실습에서 변경되거나 추가된 부분만 작성하도록 하겠다.

(1) UserJoinRequset DTO

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
// 사용자에게 데이터를 입력받기 위한 DTO
public class UserJoinRequest {
    private String userName;
    private String password;
    private String email;

    // 사용자에게 입력받은 데이터를 Entity로 보내줌
    public User toEntity(String password){          // 비밀번호를 암호화 해야하기 때문에 password는 따로 입력받아 저장시킨다.
        return User.builder()
                .userName(this.userName)
                .password(password)
                .emailAddress(this.email)
                .build();
    }
}
  • 회원가입할때 유저에게 입력받는 데이터 DTO로 이전에는 유저에게 받는 정보를 그대로 toEntity하여 DB에 저장했는데 이제는 Security를 사용하여 비밀번호에 대해서는 암호화하여 저장해야 하기 때문에 password를 따로 매개변수로 입력받아 DB에 저장한다.

(2) UserLoginRequest DTO

@AllArgsConstructor
@Getter
public class UserLoginRequest {
    private String userName;
    private String password;
}
  • 로그인할때 유저가 입력하는 데이터 DTO

(3) UserLoginResponse DTO

@AllArgsConstructor
@Getter
public class UserLoginResponse {
    private String userName;
    private String email;
}
  • 로그인 결과를 반환해주는 DTO

Service

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;


    // Spring이 자동으로 Bean폴더를 DI를 해줌
    //  EncrypterConfig encoder = new EncrypterConfig().encodePwd(); 와 같은 의미
    private final EncrypterConfig encrypterConfig;

    // 위에 처럼 내가 설정한 Config파일을 호출하여 사용해도 되지만  Spring에서는 기존 BCryptPasswordEncoder 클래스를
    // DI를 하겠다 선언하면 알아서 해당 설정 Bean파일인 EncrypterConfig과 매칭을 시켜서 사용할 수 있게 해준다.
    private final BCryptPasswordEncoder encoder;


    // 회원가입 기능
    // 데이터가 없을경우 정상동작, 데이터가 이미 있을겨우 오류 발생(회원가입 불가)
    // 유저에게 입력받은 데이터 중복 검사 및 DB 저장
    public UserDto join(UserJoinRequest request){

        userRepository.findByUserName(request.getUserName())
    // 내가 원하는 에러코드를 만들어서 설정하기
    // enum클래스를 통해 미리 설정해둔 에러구조를 통해 에러를 넘겨준다.
                .ifPresent(user -> {
                    throw new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME,String.format("Username :"+request.getUserName()));
                });


     // 비밀번호 암호화 하는방식 2가지
     //  1. 내가 설정한 Config파일인 EncrypterConfig를 DI를받아 사용하는 법
     //     EncrypterConfig의 메서드인 encodePwd()를 호출하고 BCryptPasswordEncoder안에 있는 encode() 기능 사용
        User saveUser = userRepository.save(request.toEntity(encrypterConfig.encodePwd().encode(request.getPassword())));

     // 2. 기존 클래스인 BCryptPasswordEncoder를 DI를 받아 사용하는 법
     //     BCryptPasswordEncoder클래스안에 있는 메서드 encode() 기능 사용 => 자동으로 EncrypterConfig Bean과 연결됨
        User saveUser2 = userRepository.save(request.toEntity(encoder.encode(request.getPassword())));    // UserJoinRequest -> User Entity변환후 데이터 DB 저장 , password는 암호화 하여 저장
        
        return UserDto.fromEntity(saveUser2);    // User에게 입력받아 회원가입한 데이터를 UserDto에 저장함
    }
}
  • 암호화를 하기 위해서는 BCryptPasswordEncoder 클래스를 통해 할 수 있는데 이때 DI하는 법이 2가지가 있다.

    (1) 내가 설정한 Bean 폴더인 EncrypterConfig와 의존
    - Bean 폴더인 EncrypterConfig와 직접적으로 의존하게 됨. 하지만 사용할 때는 EncrypterConfig폴더를 한번 거쳐서 EncrypterConfig안에 있는 BCryptPasswordEncoder의 메서드를 사용할 수 있다.
    - 코드를 보면 DB에 데이터를 저장하는 부분에서 encrypterConfig.encodePwd().encode(request.getPassword()) 를 통해 EncrypterConfig안에 있는 메서드 encodePwd( )안에 BCryptPasswordEncoder의 기능인 encode를 통해 패스워드를 암호화하는 것을 알 수 있다.

    (2) BCryptPasswordEncoder 클래스 의존
    - 자동으로 위에서 BCryptPasswordEncoder 설정한 Bean 파일인 EncrypterConfig와 매핑되어 의존하게 된다. 하지만 기능적으로 사용할 때는 BCryptPasswordEncoder안에 있는 메서드를 바로 사용할 수 있다.
    - 코드를 보면 DB에 데이터를 저장하는 부분에서 바로 BCryptPasswordEncoder의 기능인 encode를 통해 패스워드를 암호화하는 것을 알 수 있다.


결과

  • 1~4번까지는 이전 실습인 그냥 데이터를 저장한 것이고 5~8번까지는 암호화하여 저장한 모습이다.


JWT

이제는 회원가입은 되었으니 로그인을 할 차례인데 로그인을 위해서는 기존에 저장되어 있는 암호화된 데이터와 유저가 입력한 데이터를 비교한 후 다르면 예외처리, 맞으면 토큰을 발행하는 방식으로 구현한다. 이때 발행되는 토큰이 JWT이다.

  • 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
  • URL로 이용할 수 있는 문자열로만 구성 → HTTP 구성요소 어디든 위치할 수 있음
  • 디지털 서명이 적용돼 있어 신뢰할 수 있음
  • 서버와의 통신에서 권한 인가를 위해 사용

1. JWT 동작 구조

  • JWT는 점으로 구분된 세 부분으로 구성된다. 순서대로 헤더(Header), 페이로드(Payload), 서명(Sinature)로 구성된다.

    토큰의 타입과 해시 암호화 알고리즘으로 구성이 되어 있다. 첫째는 토큰의 유형을 나타내고 두번째는 해시 알고리즘(SHA256, RSA, HS256..)을 나타내는 부분이다.

    Header 구조
    {
     "typ":"JWT",
     "alg":"HS256"
    }

    Payload

    토큰에 담을 클레임 정보를 보함하고 있다. 다음은 표준스펙으로 정의되어 있는 Claim 스펙이다.(필수는 아니고 대표적인 것들이다)
    (1) iss(Issuer) : 토큰 발급자
    (2) sub(Subject) : 토큰 제목
    (3) aud(Audience) : 토큰 대상자
    (4) exp(Expiration Time) : 토큰 만료 시간
    (5) nbf(Not Before) : 토큰 활성 날짜(이 날짜 이전의 토큰은 활성화되지 않음을 보장)
    (6) iat(Issued At) : 토큰 발급 시간
    (7) jti(JWT Id) : JWT 토큰 식별자(Issuer가 여러명일 때 이를 구분하기 위한 값)

    Payload 구조
    {
    "sub":"payload test",
    "iss":"manager",
    "exp":"1636989715",
    "iat":"1636987715"
    }

    Sinature

    가장 중요한 서명으로 암호화가 되어 있으므로 JWT.IO를 통해 이미지로 설명하겠다.
    지금까지는 Header와 Payload를 보여줄 때는 인코딩 되어있던 값들을 JWT에 담겨있는 것처럼 디코딩된 상태를 사용했었는데 이제는 Header와 Payload를 디코딩한 값을 아래처럼 합치고 이를 서버가 가지고 있는 개인키(your-256-bit-secret)를 통해 암호화할 수 있다. 현재 저 빈칸부분에 서버에서 설정한 개인키값을 넣으면 암호화된 값을 풀어낼 수 있다. 따라서 개인키는 절대 외부에 노출이 되서는 안된다.


2. 기본 설정

Spring Security를 사용하기 위해서는 Gradle에 해당 dependencies를 추가해줘야 한다.
Intellij에서 직접 찾아서 추가해줘도 되고 mvnrepository 홈페이지를 통해 추가해줘도 된다.

// mvnrepository에서 검색해서 넣음
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

Utiles

(1) application.yml

jwt:
  token:
    secret: hello
  • 개인키는 외부에 노출이 되면 안되기 때문에 application 설정파일에 따로 선언을 해준다. 하지만 application파일도 이상태로 올리면 노출이 되기 때문에 secret 값안에는 가짜 데이터를 넣어주고 진짜 데이터는 SpringBoot 실행파일의 Environement Variables안에 넣어 숨겨준다.

(2) JwtTokenUtil

public class JwtTokenUtil {
    public static String createToken(String userName, long expireTimeMs, String key) {
        Claims claims = Jwts.claims(); // 일종의 map
        claims.put("userName", userName);

        return Jwts.builder()       // 토큰 생성
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))      //  시작 시간 : 현재 시간기준으로 만들어짐
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))     // 끝나는 시간 : 지금 시간 + 유지할 시간(입력받아옴)
                .signWith(SignatureAlgorithm.HS256, key)
                .compact()
                ;
    }
}
  • Token을 생성하는 공간으로 기본 값들을 넣어 설정하는 공간이기도 한다.
  • 위에서 말한거처럼 Token에는 3가지로 구성이 되어 있는데
    Header,Sinature => .signWith(SignatureAlgorithm.HS256, key)을 통해 HS256을 사용한다고 설정하고 개인키를 넘겨 받아 해당 키를 통해 토큰값을 암호화 한다.

    Payload => Claim의 정보를 넣는데 Claim은 일종의 map의 구조로 되어 있어 이미 만들어져 있는 Jwts의 claims 구조를 받아오고 추가로 put을 통해 원하는 데이터를 넣을수도 있다.
    그리고 자신만의 토큰을 만들기 위해 Jwts로 받아온 claims를 덮어쓰기를 통해 토큰을 생성한다.

3. CODE

이전 실습에 변경되거나 추가된 내용만 작성함

DTO

UserLoginResponse

@AllArgsConstructor
@Getter
public class UserLoginResponse {
    private String token;
}
  • 로그인을 했을때 반환값은 토큰의 인코딩값만 반환시켜 줄 수 있도록 한다.
  • 토큰안에는 유저의 데이터가 암호화되어 저장되어 있다.

Service

UserService

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;


    // Spring이 자동으로 Bean폴더를 DI를 해줌
    //  EncrypterConfig encoder = new EncrypterConfig().encodePwd(); 와 같은 의미
    private final EncrypterConfig encrypterConfig;

    // 위에 처럼 내가 설정한 Config파일을 호출하여 사용해도 되지만  Spring에서는 기존 BCryptPasswordEncoder 클래스를
    // DI를 하겠다 선언하면 알아서 해당 설정 Bean파일인 EncrypterConfig과 매칭을 시켜서 사용할 수 있게 해준다.
    private final BCryptPasswordEncoder encoder;

    // jwt 토큰에서 토큰 이름을 숨겨두고 해당 이름을 호출한다.(코드상에 토큰 이름이 있으면 절대 안됨, 바로 해킹당함)
    // application.yml 파일에 토큰 가짜 이름을 넣는다(실제 값은 environment variables에 넣는다)
    @Value("${jwt.token.secret}")
    private String secretkey;   // application.yml에서 설정한 token 키의 값을 저장함
    private long expireTimeMs = 1000*60*60; // 토큰 1시간

    // 회원가입 기능
    public UserDto join(UserJoinRequest request){
    // 데이터가 없을경우 정상동작, 데이터가 이미 있을겨우 오류 발생(회원가입 불가)
    // 유저에게 입력받은 데이터 중복 검사 및 DB 저장


        userRepository.findByUserName(request.getUserName())
    // 1. RuntimeException 에러타임 보내기(에러 설정클래스에서 RuntimeException에 해당하는 메서드 실행됨
    //            .ifPresent(user -> new RuntimeException("해당 UserName이 중복 됩니다"));   // 데이터가 있을경우 예외처리(콘솔에만 출력됨)

    // 2. 내가 원하는 에러코드를 만들어서 설정하기
    // enum클래스를 통해 미리 설정해둔 에러구조를 통해 에러를 넘겨준다.
                .ifPresent(user -> {
                    throw new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME,String.format("Username :"+request.getUserName()));
                });


     // 비밀번호 암호화 하는방식 2가지
     //  1. 내가 설정한 Config파일인 EncrypterConfig를 DI를받아 사용하는 법
     //     EncrypterConfig의 메서드인 encodePwd()를 호출하고 BCryptPasswordEncoder안에 있는 encode() 기능 사용
        User saveUser = userRepository.save(request.toEntity(encrypterConfig.encodePwd().encode(request.getPassword())));

     // 2. 기존 클래스인 BCryptPasswordEncoder를 DI를 받아 사용하는 법
     //     BCryptPasswordEncoder클래스안에 있는 메서드 encode() 기능 사용 => 자동으로 EncrypterConfig Bean과 연결됨
        User saveUser2 = userRepository.save(request.toEntity(encoder.encode(request.getPassword())));    // UserJoinRequest -> User Entity변환후 데이터 DB 저장 , password는 암호화 하여 저장
        
        return UserDto.fromEntity(saveUser2);    // User에게 입력받아 회원가입한 데이터를 UserDto에 저장함
    }

    
    
    
    // 로그인 기능
    public String login(String userName, String password) {
        // 유저이름(ID)이 있는지 확인
        // 없다면 Not Found 에러 발생
        User user = userRepository.findByUserName(userName)
                .orElseThrow(()-> new HospitalReviewAppException(ErrorCode.NOT_FOUND,String.format("%s는 가입된 적이 없습니다.",userName)));

        // password일치 하는지 여부 확인
        if(!encoder.matches(password,user.getPassword())){      // encoder.matches는 암호화된 문자를 입력된 문자와 비교해주는 메서드이다
            throw new HospitalReviewAppException(ErrorCode.INVALID_PASSWORD,String.format("비밀번호가 틀립니다."));
        }

        // 두가지 확인 중 에외가 없다면 token 발행
        return JwtTokenUtil.createToken(userName,expireTimeMs,secretkey);
    }
}
  • 로그인 기능을 추가했는데 유저에게 입력받은 userName( ID )와 password를 가져와 Option.orElseThrow를 통해 해당 ID에 대한 데이터가 없을경우 예외처리를 한다.
  • ID는 있으나 비밀번호가 다를경우 throw를 통해 에외처리를 진행한다.(여기 부분은 Option이 아님, 암호화된 DB데이터와 암호화가 안된 데이터를 비교하는 방식이기 때문에 encoder의 기능을 사용함에 따라 일반 throw를 던져 에외처리함)
  • 만약 모두 true로 예외가 없다면 token을 발행한다. 이때, 해당 ID와 토큰 유지시간, 개인키를 입력받아 생성해준다.
  • 토큰 유지시간은 코드중복 방지를 위해 메서드 밖으로 빼내어 설정해주었고 개인키는 외부에 노출이 되면 안되기 때문에 application 설정파일에서 받아올 수 있도록 했다.

4. 결과

  • 아이디와 비밀번호를 입력하면 성공했다는 메세지와 함께 토큰이 발행이 된다. 그래서 토큰이 잘 나왔는지 확인을 위해 jwt.io 홈페이지에 넣게 되면

- 토큰에 넣은 값들이 잘 들어있는 것을 알 수 있고 이를 복호화를 위해서는 SIGNATURE에 개인키 값을 넣어야 한다.

5. TestCase(TDD)

@WebMvcTest
@WebAppConfiguration
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    UserService userService;

    @MockBean
    BCryptPasswordEncoder encoder;

    @Autowired
    ObjectMapper objectMapper;

    UserJoinRequest userJoinRequest;
    @BeforeEach         // 중복되는 코드 따로 빼내서 사용
    public void setup() {
        userJoinRequest = UserJoinRequest.builder()
                .userName("han")
                .password("1q2w3e4r")
                .email("oceanfog1@gmail.com")
                .build();
    }

    @Test
    @DisplayName("회원가입 성공")
    @WithMockUser
    void join_success() throws Exception {
        // given

        User user = userJoinRequest.toEntity(encoder.encode(userJoinRequest.getPassword()));        // 비밀번호 암호화
        UserDto userDto = UserDto.fromEntity(user);

        when(userService.join(any())).thenReturn(userDto);

        // when, then
        mockMvc.perform(post("/api/v1/users/join")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)        // Json 타입으로 사용
                        .content(objectMapper.writeValueAsBytes(userJoinRequest)))      // 삽입한 데이터 dto를 json 형식으로 변환
                .andDo(print())
                // userName 존재 여부 확인
                .andExpect(jsonPath("$..userName").exists())
                // userName의 값 비교
                .andExpect(jsonPath("$..userName").value("han"))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("회원가입 실패")
    @WithMockUser
    void join_fail() throws Exception {
        setup();

        // 이전에는 when/thenReturn을 통해 구현했는데 그렇게 하면 given 구역에서 when을 사용하면 헷갈릴 수 있으므로 given으로 구역을 표시하며 정확히 한다.
        given(userService.join(any()))
                .willThrow(new AppException(ErrorCode.DUPLICATED_USER_NAME, ""));

        mockMvc.perform(post("/api/v1/users/join")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(userJoinRequest)))
                .andDo(print())
                .andExpect(status().isConflict());

        verify(userService).join(any());
    }

    @Test
    @DisplayName("로그인 실패 - id없음")
    @WithMockUser
    void login_fail1() throws Exception{
        setup();

        given(userService.login(any(),any()))
                .willThrow(new AppException(ErrorCode.USERNAME_NOTFOUND, ""));

        mockMvc.perform(post("/api/v1/users/login")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(new UserLoginRequest(userJoinRequest.getUserName(), userJoinRequest.getPassword()))))
                .andDo(print())
                .andExpect(status().isNotFound());      // id가 없으므로 찾을수가 없다 (404)

        verify(userService).login(any(),any());
    }


    @Test
    @DisplayName("로그인 실패 - pw틀림")
    @WithMockUser
    void login_fail2() throws Exception{
        setup();


        given(userService.login(any(),any()))
                .willThrow(new AppException(ErrorCode.INVALID_PASSWORD, ""));

        mockMvc.perform(post("/api/v1/users/login")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(new UserLoginRequest(userJoinRequest.getUserName(), userJoinRequest.getPassword()))))
                .andDo(print())
                .andExpect(status().isUnauthorized());

        verify(userService).login(any(),any());
    }

    @Test
    @DisplayName("로그인 성공")
    @WithMockUser
    void login_success() throws Exception{
        String userName = "test";
        String password = "1234";

        given(userService.login(any(),any()))
                .willReturn("token");

        mockMvc.perform(post("/api/v1/users/login")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)        // Json 타입으로 사용
                        .content(objectMapper.writeValueAsBytes(new UserLoginRequest(userName, password))))      // 삽입한 데이터 dto를 json 형식으로 변환
                .andDo(print())
                // token 존재 여부
                .andExpect(status().isOk());

        verify(userService).login(any(),any());
    }

}
  • Security를 테스트 할때는 유저의 데이터가 있어 하므로 @WithMockUser를 통해 가짜 유저를 생성해서 진행한다. 만약 @WithMockUser를 선언하지 않으면 Spring Security에서 유저가 없다고 판단하여 에러가 발생한다.
  • Test는 give - when - then 구조로 진행한다.

로그인 실패

  • given을 통해 Service의 login메서드에 ID,PW의 값을 아무런 값이나 넣어도 ID에 해당하는 값이 없기 때문에 willThrow를 통해 예외처리를 한다.
  • Security Config에서 csrf( )를 설정해줬으므로 .with(csrf())를 지정해줘야지만 오류가 발생하지 않는다.
  • 상태 기대값은 예외처리가 실행됬으므로 해당 에러가 출력이 되야 하므로 isNotFound()를 출력한다.

비밀번호 실패

  • 해당 내용은 로그인 실패와 같고 오류 메세지만 다르게 설정한다.

로그인 성공

  • 이전과 달리 given에서 반환값을 예외처리가 아닌 token을 반환하도록 구현하고 상태 기대값이 정상상태인 200이 출력되도록 구현한다.
profile
Web Developer

0개의 댓글