Spring/Security

7) JWT(JSON Web Token) 설정

쿠카이든 2023. 4. 18. 18:12
728x90

아래 내용은 https://github.com/beaver84/setting-test 에서 실제 소스를 확인할 수 있습니다.

  • JWT(JSON Web Token)는 당사자 간에 정보를 JSON 개체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준( RFC 7519 )입니다.
  • JWT는 비밀( HMAC 알고리즘 포함) 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있습니다. 공개/개인 키 쌍을 사용하여 토큰에 서명할 때 서명은 개인 키를 보유한 당사자만이 서명한 당사자임을 인증합니다.(세션, 쿠키 기반 인증과 차이)
  • 권한 부여 : JWT를 사용하는 가장 일반적인 시나리오입니다. Single Sign On은 오버헤드가 적고 다양한 도메인에서 쉽게 사용할 수 있기 때문에 현재 JWT를 널리 사용하는 기능입니다.

JWT 토큰 발급 흐름도

 

JWT 로그인 로직 순서

 

  • authProvider.class 의 authenticate 메소드 실행(인자값은 new TestAuthenticationToken(loginForm.getEmail(), loginForm.getPassword()) 생성자)
    • authenticate 안에 아이디, 패스워드 유효성 검증 후, 유효하다면 Authenticate 객체에 authenticated = true 값을 넣어줌, 또한 기구현된 로직에 따라 권한을 부여
    • 또한 redis에도 동일한 값을 저장(토큰의 유효 기간 및 중복 로그인 방지 기능 적용)
  • jwtTokenProvider.class 의 generateJwtToken 메소드 실행 -> HS256 암호화 알고리즘애 따라 토큰값을 생성
    • 위에서 생성된 토큰 값을 바탕으로 MemberService 로그인 메소드에서 response.addHeader("Authorization", "Bearer " + jwtToken) 로 API요청의 헤더부에 토큰값을 입력

 

@Component
public class JwtTokenProvider {
    private static final String SECRET_KEY = "TEST_APP_API_SERVER";
    private static final int LOGIN_SESSION_EXTEND_MINUTES = 120;
    private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    private final CacheRepository cacheRepository;
    private final Gson gson;

    @Autowired
    public JwtTokenProvider(MemberJpaRepository memberRepository,
                            @Qualifier("redisRepository") CacheRepository cacheRepository) {
        this.cacheRepository = cacheRepository;
        this.gson = new Gson();
    }

    public String generateJwtToken(Member member) {
        LocalDateTime expireDate = LocalDateTimeZoneUtil.getNow().plusMinutes(LOGIN_SESSION_EXTEND_MINUTES);
        JwtBuilder builder = Jwts.builder()
                .setSubject(member.getEmail())
                .setHeader(createHeader())
                .setClaims(createClaims(member, expireDate))
                .signWith(SignatureAlgorithm.HS256, createSigningKey());

        return builder.compact();
    }

    public boolean isValidToken(String token) {
        try {
            Claims claims = getClaimsFormToken(token);
            Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
            return Objects.nonNull(member);
        } catch (ExpiredJwtException exception) {
            return false;
        } catch (JwtException exception) {
            return false;
        } catch (NullPointerException exception) {
            return false;
        }
    }

    public Authentication createAuthenticationFromToken(String token) {
        Claims claims = getClaimsFormToken(token);  // 토큰 복호화
        Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
        return new TestAuthenticationToken(member.getEmail(), member.getPassword(), member.getAuthorities());
    }

    public String getTokenFromHeader(String header) {
        return header;
    }

    private Map<String, Object> createHeader() {
        Map<String, Object> header = new HashMap<>();

        header.put("typ", "JWT");
        header.put("alg", "HS256");
        header.put("regDate", System.currentTimeMillis());

        return header;
    }

    private Map<String, Object> createClaims(Member member, LocalDateTime expireDt) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", member.getEmail());
        claims.put("claims", gson.toJson(member));
        return claims;
    }

    private Key createSigningKey() {
        byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
        return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
    }

    public Claims getClaimsFormToken(String token) {
        return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY)).parseClaimsJws(token)
                .getBody();
    }

    public void extendSession(String token) {
        Claims claims = getClaimsFormToken(token);
        Member account = gson.fromJson(claims.get("claims").toString(), Member.class);
        String sessionKey = "";
        if (account.getRole() == Role.ADMIN) {
            sessionKey = "ADMIN:";
        } else {
            sessionKey = "USER:";
        }

        cacheRepository.setValue(sessionKey + account.getEmail(), String.valueOf(account.getId()),
                LOGIN_SESSION_EXTEND_MINUTES);
    }
}
  • generateJwtToken 에서 토큰을 생성하고 웹표준(RFC7519)을 지키기 위해 압축하여 헤더에 보관한다.
  • isValidToken 에서는 이상이 없는 토큰인지 확인한 후, 유효하지 않은 토큰인 경우 false를 리턴한다.
  • createSigningKey 에서는 Base64로 변환한 후, HMAC SHA256 알고리즘으로 압축하여  페이로드에 넣는다.
    • 페이로드 예 :

참고 : https://12bme.tistory.com/130

  • TestAuthenticationToken 로 생성자 주입하여 인증을 수행한다.
  • extendSession 에서 토큰을 파싱하여 정보를 파악한 후, 기간을 연장시킨다.
728x90
  • TestAuthenticationToken.java 로그인 후 토큰 인증을 담당하는 곳
public class TestAuthenticationToken extends AbstractAuthenticationToken {
    private final Object principal;
    private Object credentials;
	
    //...1
    public TestAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }
	
    //...2
    public TestAuthenticationToken(Object principal, Object credentials,
                                   Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }

    public Object getCredentials() {
        return this.credentials;
    }

    public Object getPrincipal() {
        return this.principal;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        Assert.isTrue(!isAuthenticated,
                "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        super.setAuthenticated(false);
    }

    public void eraseCredentials() {
        super.eraseCredentials();
        this.credentials = null;
    }
}
  • 1번 생성자 -> AuthProvider 클래스의 authenticate 메서드 -> 2번 생성자 순으로 인증정보를 가져온다.

 

  • MemberService.java 에서 jwt를 통한 로그인 관련 부분
    @Transactional
    public Member login(LoginFormDto loginForm, HttpServletRequest request, HttpServletResponse response) {
        Member member = memberJpaRepository.findByEmail(loginForm.getEmail());

        if(Objects.isNull(member)) {
            throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
        }

        //클라이언트에서 온 암호를 복호화하여 DB에 저장된 암호를 복호화 한것과 비교한다.
        if (StringUtils.equals(authEncrypter.decrypt(member.getPassword()), authEncrypter.decrypt(loginForm.getPassword()))) {
            //멤버 인증하여 토큰 반환
            Authentication authentication = authProvider.authenticate(
                    new TestAuthenticationToken(loginForm.getEmail(), loginForm.getPassword()));
            SecurityContextHolder.getContext().setAuthentication(authentication);
            String jwtToken = jwtTokenProvider.generateJwtToken(member);
            response.addHeader("Authorization", "Bearer " + jwtToken);

            //토큰이 있으면 account에 토큰정보를 추가하여 출력
            if (StringUtils.isNotEmpty(loginForm.getToken())) {
                member.setToken(loginForm.getToken());
            }
            log.debug(loginForm.getEmail() + " 로 로그인에 성공하였습니다.");
            return member;
        } else {
            throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
        }
    }

 

  • SecurityConfig.java - JWT 토큰으로 유저에게 권한을 인증 또는 인가하는 곳
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private AuthenticationProvider authenticationProvider;
    private UserDetailsService userDetailsService;
    private JwtTokenProvider jwtTokenProvider;
    private AuthenticationEntryPoint authenticationEntryPoint; //토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링

    @Autowired
    public void setAdminAuthProviderFactory(
        @Qualifier("authProvider") AuthenticationProvider authProviderFactory) {
        this.authenticationProvider = authProviderFactory;
    }

    @Autowired
    public void setUserDetailsService(@Qualifier("memberService") UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Autowired
    public void setAuthenticationEntryPoint() {
        this.authenticationEntryPoint = new AuthEntryPoint();
    }

    @Autowired
    public void setTokenUtils(@Qualifier("jwtTokenProvider") JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/statics/**");
        web.ignoring().antMatchers("/statics/**/*");
    }

    @Override
    protected void configure(HttpSecurity security) throws Exception {
        security.authorizeRequests()
                .antMatchers("/api/v1/account/login").permitAll()
                .antMatchers("/**").permitAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), AnonymousAuthenticationFilter.class)
                .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
                .and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider)
                .userDetailsService(userDetailsService);
    }

 

  • AuthEntryPoint - 토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링
public class AuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException, ServletException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        ApiExceptionInfo apiException = new ApiExceptionInfo();
        apiException.setHttpStatus(HttpStatus.UNAUTHORIZED);
        apiException.setMessage("인증되지 않은 사용자입니다.");
        apiException.setSuccess(false);

        httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());

        OutputStream outputStream = httpServletResponse.getOutputStream();
        outputStream.write(new Gson().toJson(apiException).getBytes());
        outputStream.flush();
    }
}

 

  • AuthProvider - redis에 중간저장 및 유효성 검증
@Component
public class AuthProvider implements AuthenticationProvider {
    private static final int LOGIN_SESSION_EXTEND_MINUTES = 120;

    private final Logger log = LogManager.getLogger(this.getClass());

    private final CacheRepository cacheRepository;
    private final AuthEncrypter authEncrypter;
    private final MemberJpaRepository memberJpaRepository;

    public AuthProvider(CacheRepository cacheRepository,
                        AuthEncrypter authEncrypter,
                        MemberJpaRepository memberJpaRepository) {
        this.cacheRepository = cacheRepository;
        this.authEncrypter = authEncrypter;
        this.memberJpaRepository = memberJpaRepository;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Object principal = authentication.getPrincipal();

        if (Objects.isNull(principal)) {
            throw new ApiException(HttpStatus.BAD_REQUEST, "아이디를 입력해주세요.");
        }

        Member member = memberJpaRepository.findByEmail(principal.toString());

        if (Objects.isNull(member)) {
            throw new ApiException(HttpStatus.BAD_REQUEST, "존재하지 않는 아이디입니다.");
        }

        String decryptInputPassword = authEncrypter.decrypt(authentication.getCredentials().toString());
        String decryptSavedPassword = authEncrypter.decrypt(member.getPassword());

        if (!StringUtils.equals(decryptInputPassword, decryptSavedPassword)) {
            throw new ApiException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
        }

        String sessionKey = "";
        if (member.getRole() == Role.ADMIN) {
            sessionKey = "ADMIN:";
        } else {
            sessionKey = "USER:";
        }

        String cacheKey = sessionKey + member.getEmail();

        // 중복로그인 방지 임시코드
        if (StringUtils.isNotEmpty(cacheRepository.getValue(cacheKey))) {
            throw new ApiException(HttpStatus.CONFLICT, "중복로그인이 감지되었습니다.");
        }

        cacheRepository.setValue(cacheKey, String.valueOf(member.getId()), LOGIN_SESSION_EXTEND_MINUTES);

        return new TestAuthenticationToken(member.getEmail(), member.getPassword(), member.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }

}
  • cacheRepository - 입력받은 id와 pw로 토큰을 만들기 전에 redis에 임시로 저장한다. (LOGIN_SESSION_EXTEND_MINUTES를 지정, 중복 로그인 방지)

 

  • JwtAuthenticationFilter - 필터로 토큰에 대한 권한을 체크
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;
    private final Gson gson;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.gson = new Gson();
    }

    /**
     * 요청에 대한 권한을 filter에서 체크
     *
     * @param request
     * @param response
     * @param chain
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
            ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String header = httpServletRequest.getHeader("Authorization");
        if (StringUtils.isNotEmpty(header)) {
            String token = jwtTokenProvider.getTokenFromHeader(header);
            setAuthentication(token, request, response);
        } else {
            Cookie[] cookies = httpServletRequest.getCookies();
            if (ArrayUtils.isNotEmpty(cookies)) {
                for (Cookie cookie : cookies) {
                    if (StringUtils.equals(cookie.getName(), "auth")) {
                        String token = cookie.getValue();
                        setAuthentication(token, request, response);
                        break;
                    }
                }
            }
        }

        chain.doFilter(request, response);
    }

    //토큰이 유효한 경우 SecurityContext에 저장
    private void setAuthentication(String token, ServletRequest request, ServletResponse response) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        if (jwtTokenProvider.isValidToken(token)) {
            Claims claims = jwtTokenProvider.getClaimsFormToken(token);
            Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
            String jwtToken = jwtTokenProvider.generateJwtToken(member);

            jwtTokenProvider.extendSession(jwtToken);
            Authentication authentication = jwtTokenProvider.createAuthenticationFromToken(jwtToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            httpServletResponse.addHeader("Authorization", jwtToken);
        } else {
            throw new ApiException(HttpStatus.UNAUTHORIZED, "승인되지 않은 요청입니다.");
        }
    }
}

 

 

200으로 로그인
헤더에 토큰이 정상적으로 생성됨을 확인
redis에 로그인 데이터가 입력됨을 확인

 

참고 : https://jwt.io/

728x90