[Spring] Security - JWT

얄루얄루·2022년 12월 19일
4

Spring

목록 보기
8/14

JWT Authentication

JWT는 Bearer 토큰을 이용, Basic Authentication 방식처럼 헤더에 토큰 정보를 넣어 인증에 활용한다.

쿠키, 세션이 필요없기 때문에 같은 세션을 공유하지 않는 RESTful API에서 인증을 위해 많이 사용하곤 한다.

다만, 토큰은 한번 발행되고 나면 유출되었을 시에 만능키처럼 활용될 수 있다는 단점이 있기는 하다. 그 때문에 유효기간이 꽤나 짧은 편이라 재발급을 자주 해야 한다. 이를 위해 Refresh Token을 따로 발행해 인증용으로 사용하는 Access Token이 만료되었을 시에 새로운 토큰을 발행할 수 있도록 한다.

대체적인 이론은 이전의 글들에서 다뤘으므로, 여기서는 JWT 인증을 Spring Boot를 통해 직접 구현해보도록 하자.

Review - Spring Security Authentication

구현에 앞서 Authentication의 구조를 다시 한번 짚고 넘어가자

  1. Http 요청이 들어온다.

  2. 들어온 요청이 AuthenticationFilter에 걸리면, 요청의 payload로부터 ID/PW를 추출하는데 이를 User Credentials 즉, 사용자 자격 증명 정보라 한다. 이 정보를 기반으로 해 Authentication의 구현체의 일종인 UsernamePasswordAuthenticationToken 객체를 생성한다.

  3. AuthenticationManager는 각 필터들의 인증 절차를 정의한다. 이 친구는 Interface이기 때문에 실질적인 일은 그 구현체인 ProviderManager가 한다.

  4. 3번에서 나온 친구 이름이 ProviderManager이다. 관리를 하려면 관리 대상도 있어야 할 것 아닌가. 그 관리 대상이 AuthenticationProvider이다. UsernamepasswordProvider, RememberMeProvider 등 각 인증 종류별로 provider가 있다고 보면 되며, 실질적인 인증 과정의 구현은 여기서 이루어진다.

❗여기서 잠깐
AuthenticationManager도 인증에 관여하고, AuthenticationProvider도 인증이 관여한다?
헷갈릴 수 있겠지만, 역할이 엄연히 다르다.
Manager는 이 유저가 인증에 성공하면 ~~하게 처리하고, 실패하면 ~~하게 처리하고 하는 등의 인증 과정의 전체적인 절차만을 담당하고, 실제로 이 유저가 인증이 되는 유저인지 아닌지 판단하는 것은 Provider에 하청을 준다.

  1. AuthenticationProvider는 인증을 수행하기 위해 ID/PW 등의 자격 증명 정보를 DB 같은 지정된 저장소로부터 받아오는데, 이 때 UserDetailsService를 통해 UserDetails 객체 형태로 받아오게 된다.

  2. UserDetailsService는 DB 내에 문자열 형태로 저장되어 있는 username, password 등의 정보를 UserDetails 형태의 객체로 변환하는 일을 한다.

  3. UserDetails 객체를 받은 AuthenticationProvider 이를 사용자의 입력 정보와 비교한다.

  4. 인증이 성공적으로 되었다면, 그를 증명하면 Authentication 객체를 SecurityContextHolder 내의 SecurityContext에 저장한다. 그 후, AuthenticationSuccessHandler가 실행된다.

  5. 실패했다면 AuthenticationException이 던져지고 AuthenticationFailureHandler가 실행된다.

⭐그림 왼쪽 아래에 보면 Authentication 객체에 Principal + Authorities 즉, username과 권한 밖에 없다. 비밀번호가 없다! 왜?
그 이유는 인증에 성공한 경우 유출을 막기 위해 비밀번호 등의 정보는 ProviderManager에 의해 지워지기 때문이다.

JWT in Spring Security

전체적인 인증 방법은 잘 알아봤다.

이번엔 JWT라는 특정한 인증법의 구조에 대해서도 살펴보자.

방금 전에 학습한 인증 구조를 생각하며 보면, 크게 다를 것은 없다.

특이점을 몇 개 찾아보자.

  1. JwtAuthTokenFilter : Spring Security - 1을 주의깊게 봤다면 저런 이름의 기본 Filter는 제공되지 않는다는 것을 알 수 있다. 즉, 이 친구는 커스텀 필터이다. 그림에 친절하게 OncePerRequestFilter를 상속받아 구현하라고 나와있다.

  2. JwtProvider : JWT는 암호화 방식, 비밀키의 구성에 따라 암호화, 복호화 방법이 천차만별이다. 게다가 JWT를 이용해 인증을 하면 쿠키나 세션에 인증 정보가 담겨서 오는 게 아니라, Http Request의 Header에 담겨서 오게 된다. 이를 해독해 User Credentials를 알아내야 UsernamePasswordAuthToken을 만들고 그 다음 인증 절차를 수행할 수 있다. 이를 위해 JWT를 생성하고, 검증하고, 변환하는 담당자가 하나 필요하다.

이 정도 될 것 같다.

정리하자면,

인증을 수행하는데 필요한 것은

  1. Filter (기본 제공되는 것이 있다면 딱히 구현할 필요없음)
  2. AuthenticationManager (딱히 구현할 필요없음)
  3. AuthenticationProvider (기본 제공되는 것을 그냥 써도 무방)
  4. (Optional) TokenProvider (이 경우에는 JwtProvider)

이 4 녀석 정도 되겠다.

JWT 인증 방식의 경우에는 1번과 4번의 구현이 필요하고, 그 외에 CustomProvider를 사용하고 싶다면 구현해서 넣어도 무방하다.

JWT 구조

다음으로는 JWT 자체에 대해 잠시 짚고 넘어가자.

JWT는 3개의 영역, header, payload, verify signature 의 파트를 가지고 있다.

  • header: 암호화 방식, 타입 등을 포함한다.
  • payload: username, 유효기간 따위를 포함하는 본문이다.
  • verify signature: 조작 방지용 키라고 보면 된다. Base64 방식으로 인코딩한 Header, Payload, Secret key를 합친 값을 가진다.

인증 절차

  1. 사용자가 로그인 요청을 보낸다.

  2. 서버는 인증 과정을 처리하고, 성공했을 경우, 해당 사용자의 고유 ID 값을 부여해 기타 정보와 함께 Payload에 집어넣는다.

  3. 비밀키를 이용해 Access Token을 발급한다.

  4. 사용자는 Access Token을 받아 저장 후, 인증이 필요한 요청을 할 때 토큰을 헤더에 넣어 보낸다.

  5. 서버에서는 해당 토큰의 Verify Signature를 비밀키로 복호화해 조작 여부, 유효기간을 따져 유효성을 확인한다.

  6. 검증이 완료되었을 경우, Payload를 디코딩 해 사용자의 ID에 맞는 데이터를 가져온다.

Dependency

이제 구현을 해보자. 우선 의존성부터 추가해보자.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

implementation 'io.jsonwebtoken:jjwt:0.9.1'

JwtAuthFilter

그리고는 커스텀 필터를 만들자.

// 모든 Security filter는 Bean으로 등록되어 ContextLoaderListener에 의해 로드된다.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    // JWT의 생성, 해독, 변환 등을 담당할 녀석
    private final JwtProvider jwtProvider;

    // 헤더 내부에서 JWT 용으로 사용 할 Key이다.
    // 보통 Authorization 이라고 붙인다.
    public static final String HEADER_KEY = "Authorization";

    // 인증 타입을 의미한다. JWT는 Bearer 토큰의 일종이라고 Spring security 3편에서 말했다.
    // 꼭 Bearer 라고 쓸 필요는 없긴 하다. 하지만 ABCDEF 따위 보다는 의미 있는 이름이 낫다.
    public static final String PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
    								HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        // 헤더에서 토큰 부분을 분리
        String token = resolveTokenFromRequest(request);

        // 토큰 유효성 검증
        if(StringUtils.hasText(token) && jwtProvider.validateToken(token)){
            // Authentication 객체 받아오기
            Authentication auth = jwtProvider.getAuthentication(token);
            // SecurityContextHoler에 저장
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
    
    private String resolveTokenFromRequest(HttpServletRequest request){
        // 헤더에서 토큰 부분을 분리
        String token = request.getHeader(HEADER_KEY);

        // 해당 키에 해당하는 헤더가 존재하고, 그 값이 Bearer로 시작한다면 (즉 JWT가 있다면)
        if(!ObjectUtils.isEmpty(token) && token.startsWith(PREFIX)) {
            // PREFIX 부분을 날리고 JWT만 token에 할당한다.
            return token.substring(PREFIX.length());
        }
        
        return null;
    }
}

JwtProvider

필터에서 쓰이는 JwtProvider를 만들어 줄 건데, 그 전에 암호화 용 비밀키를 장만하도록 하자.

평범한 문자열을 기록해두고 Base64.getEncoder()를 이용해서 만들 수도 있는데,

굳이? 라는 생각이 든다. 어차피 동일한 키라면 매번 새로 만들 필요는 없지 않나 싶다.

Linux나 Mac에서는

echo 문자열 | base64

를 이용해 편하게 생성이 가능하다.

Windows cli에서도 가능은 하지만...굉장히 불편하기 때문에
https://www.base64encode.org/
그냥 웹사이트를 쓰자.

JsonWebTokenAuthenticationWithSpringBootTestProjectSecretKey

이 문자열을 넣고 인코딩을 하면,

SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5

요런 결과가 나온다.

이것을 application.yml 파일에 넣어 줄 것이다.

spring:
  jwt:
    secret: SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5

만약 yml형식이 아니라 properties 포멧을 그냥 쓰고 있다면

spring.jwt.secret=SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5

이렇게 해주면 된다.

이제 진짜로 JwtProvider를 구현해보자.

여기서의 JwtProvider는 AuthenticationProvider의 역할도 겸임한다.

이유는 Spring Security의 AuthenticationManager에 AuthenticationProvider를 연결시키면 인증을 위해 authenticate 메소드를 호출하게 된다.

문제는 이 메소드의 인자가 Authentication의 구현체라는 것이다.

그리고 JWT는 정보를 추출해 Autehntication 구현체를 만들기 전까지는 기본적으로 문자열이기에 이 메소드를 이용할 수 없다.

보다 정확히 말하자면 CustomToken 클래스를 이용해 JWT를 감싸는 형태로 만들면 이용이 가능하지만, 성가실 뿐더러 어떤 성능의 향상이 있는 것도 아니다.

다른 방법으로는 AuthenticationProvider 안에 오버라이드 된 메소드 외에 인자로 JWT를 즉, String형을 받을 수 있는 메소드를 따로 선언해 사용할 수 있다.

그 말은, 다시 말해, 굳이 AuthenticationManager에 등록하는 형태를 취하지 않아도 해당 메소드만을 선언해 사용하면 된다는 말과 동일하다.

그렇기 때문에 Jwt의 생성과 변환을 관리하는 JwtProvider 클래스에 검증 부분도 합쳐서 넣은 것이다.

@Component
@RequiredArgsConstructor
public class JwtProvider {

    private static final String KEY_ROLES = "roles";
    private static final long EXPIRE_TIME = 1000 * 60 * 30; // 30 mins

    private final UserDetailsService userDetailsService;

    @Value("{spring.jwt.secret}")
    private String secretKey;

    public String generateToken(String username, List<String> roles) {
        // Claims 란 JWT의 payload 부분에 들어가는 데이터 단위라고 보면 된다.
        // Map<String, Object>를 상속하고 있기 때문에 key, value 형식으로 값을 넣을 수 있다.
        Claims claims = Jwts.claims().setSubject(username); // username
        claims.put(KEY_ROLES, roles); // 권한

        // 토큰 생성 시간
        Date now = new Date();
        // 토큰 만료 시간
        Date expireDate = new Date(now.getTime() + EXPIRE_TIME);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(expireDate)
            // 사용할 암호화 알고리즘, 비밀키
            .signWith(SignatureAlgorithm.HS512, secretKey)
            .compact();
    }

    // 토큰 유효성 확인
    public boolean validateToken(String token){
        if(!StringUtils.hasText(token)){
            return false;
        }
        Claims claims = getClaims(token);
        return !claims.getExpiration().before(new Date());
    }

    // 토큰 기반으로 Authentication 구현체 생성
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(getUserName(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "",
            userDetails.getAuthorities());
    }

    // Claims에서 username 추출
    private String getUserName(String token) {
        return getClaims(token).getSubject();
    }

    // 토큰에서 Claims 추출
    private Claims getClaims(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
        } catch (SignatureException e) {
            throw new BadCredentialsException("잘못된 비밀키", e);
        } catch (ExpiredJwtException e) {
            throw new BadCredentialsException("만료된 토큰", e);
        } catch (MalformedJwtException e) {
            throw new BadCredentialsException("유효하지 않은 구성의 토큰", e);
        } catch (UnsupportedJwtException e) {
            throw new BadCredentialsException("지원되지 않는 형식이나 구성의 토큰", e);
        } catch (IllegalArgumentException e) {
            throw new BadCredentialsException("잘못된 입력값", e);
        }
        return claims;
    }
}

SecurityConfig

필터 부분의 구현은 끝났다.

그럼 이제 필요한 것은 Filter, AuthenticationManager에 대한 설정이다.

이는 대개 하나의 Configuration에서 정의하므로 SecurityConfig를 작성하도록 하자.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    public static final String ROLE_ADMIN = "ADMIN";
    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // Basic 방식도 헤더에 토큰을 넣어 보내기 때문에 혼동이 없도록 비활성화 해버리기
            .httpBasic().disable()
            // csrf 체크를 하는 부분인데, jwt 방식은 세션을 사용하지 않기 때문에 끄는 것이 좋다
            .csrf().disable()
            // Spring Security에서 세션을 만들지 않고, 있어도 사용하지 않는다는 설정
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            // 각 요청에 대한 권한 설정
            .authorizeRequests()
            // 아래 요청들은 인증이 없어도 허용
            .antMatchers("/**/signup", "/**/signin").permitAll()
            // 아래 요청은 관리자 권한이 있어야 허용
            .antMatchers("/admin").hasRole(ROLE_ADMIN)
            // 아래 요청은 유저 or 관리자 권한이 있어야 허용
            .antMatchers("/user").hasAnyRole(ROLE_USER, ROLE_ADMIN)
            .and()
            // 커스텀 필터를 ID/PW 기반으로 인증하는 기본 필터 앞에 넣어서 먼저 인증을 시도하게 함
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

UserDetailsService 구현

UserDetailsService는 Interface이다.

그렇기 때문에 구현체가 필요한데, 보통은 회원 관련 서비스를 그 구현체로 사용하는 편이다.

@Service
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username)
        throws UsernameNotFoundException {

        Member member = memberRepository.findById(username)
            .orElseThrow(() -> new UsernameNotFoundException("회원 정보가 일치하지 않습니다."));

        // 이렇게 하지 않고 Member implements UserDetails를 하고 메소드 오버라이드를 해도 됨
        // UserDetails 또한 Interface이기에 그 구현체인 User 객체를 통해 사용자 정보를
        // 넘길 수 있다는 걸 보여주고 싶었음
        return new User(member.getUsername(), member.getPassword(),
            member.getRoles().stream().map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList()));
    }

    public Member register(String username, String password, List<String> roles) {
        if (memberRepository.existsByUsername(username)) {
            throw new RuntimeException("이미 존재하는 ID 입니다 -> " + username);
        }

        password = passwordEncoder.encode(password);

        return memberRepository.save(Member.builder()
            .username(username)
            .password(password)
            .roles(roles)
            .build());
    }

    public Member authenticate(String username, String password) {
        Member member = memberRepository.findById(username)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 ID 입니다"));

        // 패스워드 비교를 할 때에는 인코딩 된 값을 2번째 인자로
        // 이유는 2번째 인자가 salt 자리이며, 여기에 인코딩 된 패스워드를 넣어줘야
        // 동일한 값으로 변환이 된다.
        if (!passwordEncoder.matches(password, member.getPassword())){
            throw new RuntimeException("비밀번호가 일치하지 않습니다.");
        }
        return member;
    }
}

App Config

MemberService에서 사용할 PasswordEncoder를 Bean으로 등록해 줄 녀석이다.

@Configuration
public class AppConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

흔히 하듯 SecurityConfig에 집어넣으면 circuler reference가 일어나며 실행이 되지 않는다.

무슨 소리냐면,

SecurityConfig는 멤버로 JwtAuthFilter를 갖는다.

JwtAuthFilter는 멤버로 JwtProvider를 갖고,

JwtProvider는 멤버로 UserDetailsService의 구현체인 MemberService를 갖는다.

MemberService는 멤버로 PasswordEncoder를 갖는데, 이게 SecurityConfig 안에 존재하면 MemberService가 SecurityConfig를 참조하게 되어

머리와 꼬리가 이어진 원형 참조가 발생한다는 소리다.

Repository & Entity & Converter

Repository

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    boolean existsByUsername(String username);

}

Entity

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Member {
    @Id
    private String username;
    private String password;
    // List가 MySql에 저장 불가능하므로 컨버터를 이용
    // 객체 -> DB 일 때는 [a, b] -> "a,b"
    // DB -> 객체 일 때는 "a,b" -> [a, b]
    // 컨버터 없이 @ElementCollection을 사용 할 수도 있음. 그 경우엔 DB 내 테이블이 분리
    @Convert(converter = MemberRoleConverter.class)
    private List<String> roles;
}

Converter

@Converter
public class MemberRoleConverter implements AttributeConverter<List<String>, String> {

    private static final String SPLIT_CHAR = ",";

    @Override
    public String convertToDatabaseColumn(List<String> attribute) {
        return attribute.stream().map(String::valueOf)
            .collect(Collectors.joining(SPLIT_CHAR));
    }

    @Override
    public List<String> convertToEntityAttribute(String dbData) {
        return Arrays.stream(dbData.split(SPLIT_CHAR))
            .collect(Collectors.toList());
    }
}

왜 컨버터 같은 걸 쓰느냐? MySql의 field에 list가 저장이 안되니까.

컨버터를 사용하면 DB 내에 아래처럼 저장이 된다.

usernamepasswordroles
user1234ROLE_USER
admin1234ROLE_USER,ROLE_ADMIN

이 방식이 싫다면 주석처럼 @ElementCollection 어노테이션을 붙일 수도 있다.

다만 이 때에는

usernamepassword
user1234
admin1234

roles가 빠진 member 테이블 하나와

usernameroles
userROLE_USER
adminROLE_USER
adminROLE_ADMIN

member 테이블의 pk와 role만을 가진 테이블 하나

이렇게 2개의 테이블로 나뉘어 저장되게 된다.

그래서 getAuthority를 할 때 DB 접근을 위해 Transaction Session을 한 번 더 열어줘야 해서 성가시다.

Controller

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final MemberService memberService;
    private final JwtProvider jwtProvider;

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestBody Auth.SignUp request){
        return ResponseEntity.ok(memberService.register(
                    request.getUsername(), request.getPassword(), request.getRoles()
            ));
    }

    @PostMapping("/signin")
    public ResponseEntity<?> signin(@RequestBody Auth.SignIn request){
        // 패스워드 검증
        Member member = memberService.authenticate(
        	request.getUsername(), request.getPassword());
        // 토큰 생성 & 반환
        return ResponseEntity.ok(
        		jwtProvider.generateToken(member.getUsername(), member.getRoles()));
    }
}

Auth Request

public class Auth {

    @Getter
    @Setter
    @AllArgsConstructor
    public static class SignUp{
        private String username;
        private String password;
        private List<String> roles;
    }

    @Getter
    @Setter
    @AllArgsConstructor
    public static class SignIn{
        private String username;
        private String password;
    }

}

Test

Postman을 통해 테스트를 해보자.

그 전에 간단한 테스트용 컨트롤러를 하나 만들자.

@RestController
public class TestController {

    @GetMapping("/admin")
    public ResponseEntity<?> testAdmin(){
        return ResponseEntity.ok("관리자용 API 접근 성공!");
    }

    @GetMapping("/user")
    public ResponseEntity<?> testUser(){
        return ResponseEntity.ok("유저용 API 접근 성공!");
    }
}

/admin 주소에 대한 요청은 ADMIN 권한이 있어야만 접근이 허용된다고 SecurityConfig 내에 설정한 바가 있다.

/user 는 마찬가지로 USER 권한이 있어야 한다.

먼저 토큰 없이 /user 에 접근해보겠다.

// GET : localhost:8080/user
// Response
{
    "timestamp": "2022-12-19T01:34:12.751+00:00",
    "status": 403,
    "error": "Forbidden",
    "message": "Access Denied",
    "path": "/user"
}

접근이 거부되었다.

회원가입 후 로그인을 하고 다시 접근해보자.

회원가입의 request와 response는 위와 같다. 입력한 비밀번호가 인코딩되어 돌아왔다.

다음은 로그인이다.

응답으로 JWT가 날아왔다.

복사해서 아까 정한 규칙에 따라 HttpHeader에 넣은 다음 다시 /user에 접근을 시도해보자.

아까 정한 규칙 상으로는 Header의 key로는 "Authorization"을 사용, 값의 앞에 "Bearer "를 붙이기로 했었다.

무야호!!!! 접근에 성공했다.

내친김에 관리자 API에도 접근해보자. 이대로 관리권한을 탈취해버리는 거다.

뻔하게도 실패했다.

이번엔 토큰의 위조 및 변조를 해보자. 먹힐지도 모른다.

Header에 토큰을 어떻게 넣는지 위에 나와있으니, 여기부턴 사진이 아닌 글로 대체한다.

// 토큰을 위조했다
// Response
{
    "timestamp": "2022-12-19T01:47:28.654+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "org.springframework.security.authentication.BadCredentialsException: 잘못된 비밀키...어쩌고 저쩌고
}

JWT signature가 일치하지 않아 유효성 통과에 실패했다는 소리다.

이번엔 관리자 계정을 만들어서 들어가보자.

// POST : localhost:8080/auth/signup
// request
{
    "username" : "admin",
    "password" : 1234,
    "roles" : ["ROLE_ADMIN"]
}
// response
{
    "username": "admin",
    "password": "$2a$10$njynAFe4CYHhL3c0D65myODPSzk1NxJdoYNIT4syC8oacjw34wx6W",
    "roles": [
        "ROLE_ADMIN"
    ]
}

/user 에 접근

// response
유저용 API 접근 성공!

/admin에 접근

관리자용 API 접근 성공!

정상적으로 접근되었다.

참고로, 여기선 나오지 않았지만 role은 여러개를 가질 수 있다.

["ROLE_USER", "ROLE_ADMIN"]

이런 식으로 말이다.

JwtProvider Test

테스트 할 때 @Value를 이용한 매핑이 잘 안돼서
private String secretKey = "SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5";
이런식으로 JwtProvider 안에 직접 값을 넣고 했다.

@ExtendWith(SpringExtension.class)
class JwtProviderTest {

    @Mock
    UserDetailsService userDetailsService;

    @InjectMocks
    JwtProvider jwtProvider;

    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    private final String secretKey = "SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5";
    private final String invalidKey = "FJWOIFVNROVJQERFOOQWKLVHVIQOUJVNVNSKJDNVOQNVIQNVINKNVASJD";

    private static final long EXPIRE_TIME = 1000 * 60 * 30;
    private Member member;
    private UserDetails userDetails;

    private String generateToken(Member member, long time, String key){
        Claims claims = Jwts.claims().setSubject(member.getUsername());
        claims.put("roles", member.getRoles());
        Date now = new Date();
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + time))
            .signWith(SignatureAlgorithm.HS512, key)
            .compact();
    }

    @BeforeEach
    void setUp() {
        member = Member.builder()
            .username("user")
            .password(passwordEncoder.encode("1234"))
            .roles(Collections.singletonList("ROLE_USER"))
            .build();

        userDetails = new User(member.getUsername(),
            member.getPassword(),
            member.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList()));
    }

    @Test
    @DisplayName("토큰 유효성 검증 성공")
    void success_validateToken() {
        //given
        String token = generateToken(member, EXPIRE_TIME, secretKey);

        //when
        boolean result = jwtProvider.validateToken(token);

        //then
        assertTrue(result);
    }

    @Test
    @DisplayName("토큰 유효성 검증 실패 - 만료된 토큰")
    void fail_validateToken_expiredToken() {
        //given
        String token = generateToken(member, -EXPIRE_TIME, secretKey);

        //when
        Exception exception = assertThrows(BadCredentialsException.class, () ->
            jwtProvider.validateToken(token));

        //then
        assertEquals("만료된 토큰", exception.getMessage());
    }

    @Test
    @DisplayName("토큰 유효성 검증 실패 - 잘못된 비밀키")
    void fail_validateToken_invalidSignature() {
        //given
        String token = generateToken(member, EXPIRE_TIME, invalidKey);

        //when
        Exception exception = assertThrows(BadCredentialsException.class, () ->
            jwtProvider.validateToken(token));

        //then
        assertEquals("잘못된 비밀키", exception.getMessage());
    }

    @Test
    @DisplayName("인증 객체 생성 성공")
    void success_getAuthentication() {
        //given
        String token = generateToken(member, EXPIRE_TIME, secretKey);
        given(userDetailsService.loadUserByUsername(anyString()))
            .willReturn(userDetails);

        //when
        Authentication authentication = jwtProvider.getAuthentication(token);

        //then
        assertEquals(member.getUsername(), authentication.getName());
        assertEquals("", authentication.getCredentials());
        assertEquals(member.getRoles().toString(), authentication.getAuthorities().toString());
    }
}

AuthController Test

API를 통해 토큰이 제대로 발행되는지 보는 테스트다.

@WebMvcTest(AuthController.class)
class AuthControllerTest {
    @MockBean
    private MemberService memberService;

    @MockBean
    private JwtProvider jwtProvider;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    private final String secretKey = "SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5";

    private static final long EXPIRE_TIME = 1000 * 60 * 30;

    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    private final Member member = Member.builder()
        .username("user")
        .password(passwordEncoder.encode("1234"))
        .roles(Collections.singletonList("ROLE_USER"))
        .build();;

    private String generateToken(Member member, long time, String key){
        Claims claims = Jwts.claims().setSubject(member.getUsername());
        claims.put("roles", member.getRoles());
        Date now = new Date();
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + time))
            .signWith(SignatureAlgorithm.HS512, key)
            .compact();
    }

    @Test
    void success_createToken() throws Exception {
        //given
        String token = generateToken(member, EXPIRE_TIME, secretKey);
        given(memberService.authenticate(anyString(), anyString()))
            .willReturn(member);
        given(jwtProvider.generateToken(anyString(), any()))
            .willReturn(token);

        //when
        MvcResult result = mockMvc.perform(post("/auth/signin")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(
                new Auth.SignIn(member.getUsername(),
                    member.getPassword())
            )))
            .andExpect(status().isOk())
            .andDo(print())
            .andReturn();

        //then
        assertEquals(token, result.getResponse().getContentAsString());
    }

}

응답 (Body 부분이 토큰이다)

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"202", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = text/plain;charset=UTF-8
             Body = eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTY3MTQ1Mjk3NiwiZXhwIjoxNjcxNDU0Nzc2fQ.CmL69KhVdXXERLdjap3l1yJkNjyU4mmb4eAKvzpfERpfQvlufbwupzgPIxxgbyD84oxr_28q_FDuX19MhQME1w
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

TestController Test

인증 권한에 따른 접속 통제가 잘 되는 지 보는 테스트다.

유효하지 않은 토큰에 대한 테스트는 JwtProvider test에서 이미 했기 때문에 여기서는 제외한다.

@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {

    @MockBean
    private MemberService memberService;

    @Autowired
    private MockMvc mockMvc;

    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    private final String secretKey = "SnNvbldlYlRva2VuQXV0aGVudGljYXRpb25XaXRoU3ByaW5nQm9vdFRlc3RQcm9qZWN0U2VjcmV0S2V5";

    private static final long EXPIRE_TIME = 1000 * 60 * 30;
    private Member memberUser = Member.builder()
        .username("user")
        .password(passwordEncoder.encode("1234"))
        .roles(Collections.singletonList("ROLE_USER"))
        .build();
    private Member memberAdmin = Member.builder()
        .username("admin")
        .password(passwordEncoder.encode("1234"))
        .roles(Collections.singletonList("ROLE_ADMIN"))
        .build();;

    private String generateToken(Member member, long time, String key){
        Claims claims = Jwts.claims().setSubject(member.getUsername());
        claims.put("roles", member.getRoles());
        Date now = new Date();
        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + time))
            .signWith(SignatureAlgorithm.HS512, key)
            .compact();
    }

    private UserDetails createUserDetails(Member member){
        return new User(member.getUsername(),
            member.getPassword(),
            member.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList()));
    }

    @Test
    void success_accessUserApi() throws Exception {
        //given
        String token = generateToken(memberUser, EXPIRE_TIME, secretKey);
        given(memberService.loadUserByUsername(anyString()))
            .willReturn(createUserDetails(memberUser));

        //when
        //then
        mockMvc.perform(get("/user")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    @Test
    void fail_accessUserApi_tokenNotFound() throws Exception {
        //given
        String token = generateToken(memberUser, EXPIRE_TIME, secretKey);
        given(memberService.loadUserByUsername(anyString()))
            .willReturn(createUserDetails(memberUser));

        //when
        //then
        mockMvc.perform(get("/user"))
            .andExpect(status().isForbidden());
    }

    @Test
    void success_accessAdminApi() throws Exception {
        //given
        String token = generateToken(memberAdmin, EXPIRE_TIME, secretKey);
        given(memberService.loadUserByUsername(anyString()))
            .willReturn(createUserDetails(memberAdmin));

        //when
        //then
        mockMvc.perform(get("/admin")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    @Test
    void fail_accessAdminApi_authorityNotMatched() throws Exception {
        //given
        String token = generateToken(memberUser, EXPIRE_TIME, secretKey);
        given(memberService.loadUserByUsername(anyString()))
            .willReturn(createUserDetails(memberUser));

        //when
        //then
        mockMvc.perform(get("/admin")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isForbidden());
    }

}

References

profile
시간아 늘어라 하루 48시간으로!

0개의 댓글