2st - 토큰 복호화 및 인증 처리

Jobmania·2023년 6월 16일
0
post-thumbnail

이전의 JWTfilter를 통해 사용자의 쿠키에서 토큰값을 얻어 요청시 인증을 한다는 것까지 작성했다. 그러면 어떻게 인증을 하는지에 대해 세부적으로 로직을 까보자!

@Slf4j
public class JwtFilter extends OncePerRequestFilter {

  
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private final TokenProvider tokenProvider;


    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }



    private String resolveToken(HttpServletRequest request){ // 토큰정보 획득, 쿠키값 이슈로 'Bearer ' 변경
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer_")){
            return bearerToken.substring(7);
        }
        return null;
    }


    private String resolveCookie(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();

        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    log.info("쿠키 값 찾기 ={}",cookie.getValue().substring(7));
                    return cookie.getValue().substring(7);
                }
            }
        }

        log.info("쿠키 못꺼내옴");
        return null;
    }


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

        // Request Header 에서 토큰을 꺼냄
//        String jwt = resolveToken(servletRequest);

        // 1.  쿠키에서 값 꺼내야 됨
        String jwt = resolveCookie(servletRequest);
        log.info("jwt 값={}",jwt);

        String requestURI = servletRequest.getRequestURI();

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장

        if(StringUtils.hasText(jwt)&& tokenProvider.validateToken(jwt)){
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            // 유저 저장.
            SecurityContextHolder.getContext().setAuthentication(authentication);
              log.debug("Security Context, Member_ID ='{}' 인증정보 저장 및 조회 , uri= {} ",authentication.getName(),requestURI);
        }else {
            log.debug("통과 url : {} ",requestURI);
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

doFilterInternal에서 filter 로직이 수행된다.
1. 요청시 쿠키에서 토큰값을 꺼내온다.
2. 쿠키값에 대해서 TokenProvider에서 검증을 수행한다.
3. 인증된 사용자시 SecurityHolder에 인증정보를 저장한다.

🙄 TokenProvider에서 어떻게 인증을 할까??


@Slf4j
@Component
@PropertySource(value = "classpath:application-secret.yml")
public class TokenProvider {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final CustomUserDetailsService detailsService;

    private final Key key;

    @Autowired
    public TokenProvider(@Value("${jwt.secret}") String secretKey
                                , CustomUserDetailsService detailsService) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.detailsService = detailsService;
    }


    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }


    public Authentication getAuthentication(String token){

        //토큰 복호화~~
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();


        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // OLD - UserDetails 객체를 만들어서 Authentication 리턴
        // User principal = new User(claims.getSubject(),"", authorities);
        // return new UsernamePasswordAuthenticationToken(principal, token, authorities);

        // NEW -
        // UserDetailsService 를 통해 DB에서 username 으로 사용자 조회
        MemberDetailsImpl memberDetails = (MemberDetailsImpl) detailsService.loadUserByUsername(claims.getSubject());

        return new UsernamePasswordAuthenticationToken(memberDetails, token, memberDetails.getAuthorities());

    }

    public boolean validateToken(String token){
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        }catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명");
        }catch (ExpiredJwtException e){
            logger.info("만료된 jwt 서명");
        }catch (UnsupportedJwtException e){
            logger.info("지원되지 않은 JWT 토큰");
        }catch (IllegalArgumentException e){
            logger.info("jwt 토큰이 잘못됨");
        }
        return false;
    }

}

크게 역할은 3가지다
1. 인증 토큰값 발행 -> 로그인 시 사용
2. 토큰 위변조 검사 -> 토큰 위,변조 및 만료 검사
3. 회원 권한에 맞는 UsernamePasswordAuthenticationToken 생성 -> 검증 시 사용

// Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 1516239022 (예시)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

토큰은 탈취될 수 있기 때문에 최소한의 정보만을 담자!
여기서 최소한의 id, 권한, 만료시각, 알고리즘키를 담았다!

😀 참고로 액세스의 만료시간은 짧게 주는것이 보안상 안전.
하지만 너무 짧게 하면 사용자가 로그인을 주기적으로 반복해야 되는 불편함이 생긴다.
그래서 RefreshToken 역시 같이 발행하여 (Access토큰보다는 길게)
액세스토큰이 만료시 Refresh토큰을 확인하여 인증되었다면 Access토큰을 재발급한다.

LoginController

 @PostMapping("/auth/login")
    public String login(@Validated @ModelAttribute("member") LoginMemberRequestDto dto, BindingResult bindingResult, HttpServletResponse response,
                        RedirectAttributes redirectAttributes){

        log.info("로그인 확인 {},{}",dto.getEmail(),dto.getPassword());

        if(bindingResult.hasErrors()){
            log.error("bindingResult ={}",bindingResult.getTarget());
            return "login/loginForm";
        }

        try {
            TokenDto tokenDto = memberService.login(dto, response);

            setCookie(AUTHORIZATION, "Bearer_" + tokenDto.getAccessToken(), response);
            setCookie(REFRESH, tokenDto.getRefreshToken(), response);

        } catch (BadCredentialsException e){
            bindingResult.addError(new ObjectError("login", "로그인에 실패했습니다."));
            return "login/loginForm";
        }

//        redirectAttributes.addAttribute("member", dto);
        return "redirect:/api/member/loginHome";
    }

각 코드를 하나씩 설명하자면,
먼저 @Validated를 통해 요청에 대한 유효성 검사를 실행한다. 만약 잘못된 요청값이 나온다면 bindingResult에 담아 요청 페이지로 다시 보낸다.

유효성이 통과 되었다면 LoginService.login 메서드를 수행한다.

LoginService

@Transactional
    public TokenDto login(LoginMemberRequestDto memberRequestDto, HttpServletResponse response) {

        // 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken = memberRequestDto.toAuthentication();

        // 2. 실제로 검증 (사용자 비밀번호 체크) 이 이루어지는 부분
        //    authenticate 메서드가 실행이 될 때 CustomUserDetailsService 에서 만들었던 loadUserByUsername 메서드가 실행됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // 4. RefreshToken 저장
        RefreshToken refreshToken = RefreshToken.builder()
                .key(authentication.getName())
                .value(tokenDto.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);
       // ++ 헤더에 토큰 값 추가! And 쿠키 값으로 토큰값 전달
        response.setHeader("Authorization","Bearer_"+ tokenDto.getAccessToken());
        response.setHeader("Refresh_Token",tokenDto.getRefreshToken());

        // 5. 토큰 발급
        return tokenDto;
    }

여기서는 repository가 없는데 어떻게 멤버를 찾는지 궁금할 수 있다. 아래의 그림을 통해 Security 로직을 간략히 볼 수 있다.

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional  // UserDetails 와 Authentication 의 패스워드를 비교하고 검증하는 로직 ( 로그인)
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Optional<Member> findMember = memberRepository.findByEmail(email);
        if(findMember.isPresent()){
            Member member = findMember.get();

           log.info("memberDetails 저장 ={}",member.getEmail());

            return new MemberDetailsImpl(member);
        }else {
            throw new UsernameNotFoundException(email + "데이터 베이스에 없음");
        }

    }


   
}

여기서 회원이 입력한 id와 pw를 직접 구현한 CustomUserDetailsService 에서 repository를 찾는다. 여기서 나는 Email을 id역할로 사용했기때문에 'findByEmail'를 통해 해당 id의 멤버를 찾고 반환하고,
넘겨받은 UserDetails 와 Authentication 의 패스워드를 비교하고 검증하는 로직을 처리한다.

세부적으로는
loadUserByUsername는 DaoAuthenticationProvider에서 호출하여 Request 로 받아서 만든 authentication 와 DB 에서 꺼낸 값인 userDetails 의 비밀번호를 비교한다
자세한 로직은 여기서 확인하자!

실습

로그인 시

  • 쿠키에 토큰값이 저장되어있다.
  • 토큰값이 있는 동안에는 사용자 인증은 정상적으로 수행될 것이다.

인증 화면 검증

만약 토큰값을 삭제 하고
인증이 필요한('http://localhost:8080/api/member/loginHome")로 다시 접속을 한다면?

1.쿠키 삭제
2. 변조요청

이후는 서비스에 대해 작성 예정이다.

profile
HelloWorld에서 RealWorld로

0개의 댓글