로그인 구현 정리

Jieun·2023년 12월 15일
0

spring공부

목록 보기
1/3

- 시큐리티 설정

1. SecurityConfig.java

모든 http 요청에 대해 해당 설정 적용

  1. 예외처리 부분
  • accessDeniedHandler : 403 오류 핸들러
  • authenticationEntryPoint : 401 오류 핸들러
  1. JWT 인증필터 적용
  • JwtProvider 주입
    UsernamePasswordAuthenticationFilter 전에 해당 필터 적용
  1. 인증 절차

2. JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private static final String SUCCESS = "success";
    private static final String EXPIRED = "expired";
    private static final String DENIED = "denied";
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response
            , FilterChain filterChain) throws ServletException, IOException {
        try {
            String accessToken = jwtProvider.resolveToken(request, HttpHeaders.AUTHORIZATION);
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            //String loginId = authentication.getName();
            //String refreshToken = jwtProvider.getRefreshTokenById(loginId);

            // access token 검증
            if (StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken) == SUCCESS) {
                SecurityContextHolder.getContext().setAuthentication(authentication); // security context에 인증 정보 저장
            }

            /*else if (StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken) == EXPIRED) {
                System.out.println("Access token has expired");


                // refresh token 검증
                if (StringUtils.hasText(refreshToken) && jwtProvider.validateToken(refreshToken) == SUCCESS) {
                    System.out.println("getting new access token");
                    // access token 재발급
                    String newAccessToken = jwtProvider.generateAccessToken(authentication);

                    System.out.println("Reissue access token success");
                    response.setHeader(HttpHeaders.AUTHORIZATION, newAccessToken);
                } else { //refresh token 만료
                    System.out.println("Reissue refresh token");
                    jwtProvider.deleteRefreshToken(loginId);
                    String newAccessToken = jwtProvider.generateAccessToken(authentication);
                    String newRefreshToken = jwtProvider.regenerateRefreshToken(authentication);

                    response.setHeader(HttpHeaders.AUTHORIZATION, newAccessToken);
                }

            }

             */
        } catch (ExpiredJwtException e) {
            request.setAttribute("exception",EXPIRED);
        } catch (IllegalArgumentException  e) {
            request.setAttribute("exception",DENIED);
        }
        filterChain.doFilter(request, response);
        }

    }

길지만 결국 주석부분 빼면 간단함

  1. resolveToken : request의 헤더에서 accessToken 빼냄
  2. getAuthentication : accessToken으로부터 Authentication 얻어냄
  3. validateToken : accessToken 유효성 검사
  4. 유효한 경우, securityContext에 인증정보(Authentication) 저장
  • 토큰오류를 분리해서 처리하기 위해 catch구문으로 분리
  • setAttribute로 어떤 오류인지 기록 -> AuthenticationEntryPoint에서 처리

주석부분
: accessToken이 만료된 경우, refreshToken의 유효성 검사를 거쳐 accessToken을 재발급 / refreshToken + accessToken 재발급
-> header에 직접 넣어주기
과정을 백의 filter에서 직접 해주려 시도했으나, 실패
사실 그럴 필요도 없는 것 같음 ^-^


3. JwtProvider

@PostConstruct
    protected void init() {
        secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
        //secretKey = Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
        now= new Date().getTime();
    }

    public String generateAccessToken(Authentication authentication) {
        return generateToken(authentication, EXPIRE_TIME);
    }

    public String generateRefreshToken(Authentication authentication) {
        String refreshToken =  generateToken(authentication, REFRESH_EXPIRE_TIME);
        return refreshToken;
    }
    

    public String generateToken(Authentication authentication, long expireTime) {
        now= new Date().getTime();
        Date expiration = new Date(now+expireTime);

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setIssuedAt(new Date(now))
                .setExpiration(expiration)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }


    //token으로부터 authentication을 얻는 것
    public Authentication getAuthentication(String token) {
        //name으로부터 userDetails 얻음
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getAccount(token));
        //Authentication의 구현체(Username..) 얻음
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());

    }

    //token으로부터 name 알아냄
    public String getAccount(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey).build()
                .parseClaimsJws(token).getBody().getSubject();
    }

    //request에 헤더설정 해줘야 함
    public String resolveToken(HttpServletRequest request, String header) {
        String bearerToken = request.getHeader(header);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    public String validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            return SUCCESS;
        } catch (ExpiredJwtException e) { // 기한 만료
            return EXPIRED;
        } catch (Exception e) {
            return DENIED;
        }
        
    }

    public String getRefreshToken(Long userId) {
        Member member = memberRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("user doesn't exist"));
        return refreshTokenRepository.findByMember(member).getRefreshToken();
    }
  • generateAccessToken, generateRefreshToken -> generateToken : 인증정보(authentication)기반으로 토큰 생성, 유효시간 차이두기 위해 분리

  • getAuthentication : token으로부터 authentication (UsernamePasswordAuthenticationToken : authentication의 구현체) 얻어냄

    1. getAccount -> name 얻어냄
    2. name으로부터 userDetails 얻음
    3. User -> Authentication
  • getAccount : token으로부터 name 얻어냄 (getSubject)

  • resolveToken : request 헤더에서 토큰 뽑아냄

  • validateToken : token 유효성 검증 : parseClaim 과정에서 유효성검사 거침


4. JwtAuthenticationEntryPoint

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private static final String SUCCESS = "success";
    private static final String EXPIRED = "expired";
    private static final String DENIED = "denied";

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        String exception = (String)request.getAttribute("exception");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json; charset=UTF-8");

		//토큰 만료
        if(exception.equals(EXPIRED)) {
            setResponse(response,EXPIRED);
        }


        if(exception.equals(DENIED)) {
            setResponse(response,DENIED);
        }



    }

    public void setResponse(HttpServletResponse response,String msg) throws IOException{
        ObjectNode json = new ObjectMapper().createObjectNode();
        json.put("message", msg);
        json.put("code", HttpStatus.UNAUTHORIZED.value());
        String newResponse = new ObjectMapper().writeValueAsString(json);
        response.getWriter().write(newResponse);
    }
}

401 오류가 발생한 경우 어떤 오류인지 분류해서 Response로 보내기 위함

  • filter에서 setAttribute("exception")로 설정했던 부분 불러옴

5. CustomUserDetailsService


- 서비스

시큐리티 설정부분이 아닌 서비스 로직

  • 로그인
public TokenResponseDTO signIn(SignUpDTO dto) {
        Member member = memberRepository.findByLoginId(dto.getLoginId());
      
        if (!(passwordEncoder.matches(dto.getPassword(), member.getPassword()))) {
            throw new LogInFailure();
        }

        // user 검증
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(dto.getLoginId(), dto.getPassword());
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // token 생성
        String accessToken = jwtProvider.generateAccessToken(authentication);
        String refreshToken = jwtProvider.generateRefreshToken(authentication);
        User user = (User) authentication.getPrincipal(); // user 정보
        String userName = user.getUsername();
        RefreshToken generatedRefreshToken = RefreshToken.builder()
                .refreshToken(refreshToken)
                .member(memberRepository.findByLoginId(userName))
                .build();


        // refresh token 저장
        if (!refreshTokenRepository.existsByMember(member)) {
            refreshTokenRepository.save(generatedRefreshToken);
        } else {
            refreshTokenRepository.findByMember(member).updateRefreshToken(generatedRefreshToken.getRefreshToken());
        }


        return TokenResponseDTO.builder()
                .loginId(dto.getLoginId())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .tokenType("Bearer ")
                .build();
  1. 비밀번호 일치 확인
  2. UsernamePasswordAuthenticationToken으로 User 검증
  3. accessToken, refreshToken 생성
  4. DB에 refreshToken 저장
  5. ResponseDTO로 반환
  • AccessToken 재발급


~ 401 에러를 분류하기 위한 시도 ~

1. filterChain 이용

  • JwtExceptionFilter
  • JwtFilter

JwtFilter 앞에 예외처리 전용 JwtExceptionFilter를 사용하려고 함
JwtFilter에서 catch로 분류에서 Exception을 던지면 -> JwtExceptionFilter에서 setResponse하는 방식

  • 문제

    filte에서 직접 401Error에 Exception을 발생시켜버리면 위에서 언급했던 WHITE_LIST를 사용할 수가 없음
    : permitAll을 적용해서 filter를 지나는건 똑같고, 여기서 Exception을 발생시켜버리기 때문


2. AuthenticationEntryPoint를 이용하는 방식

  1. filterChain에서 catch로 만료, Illegal Exception을 분류 -> request에 exception이라는 이름의 Attribute로 분류해 저장
  2. filterChain -> AuthenticationEntryPoint.commence 호출 (401 발생)

여기서 code와 msg 설정해서 Response 보냄


- 결론 플로우

  1. 로그인요청 (permitAll)
    -> accessToken,refreshToken 발급
  2. 다른 API 요청: accessToken 검증
  3. accessToken 만료시 401에러 담은 Response
    -> /reisuue API 호출
    3 - 1. 새로발급한 accessToken,기존 refreshToken을 Response에 담음
    3 - 2. refreshToken도 만료인 경우 : RefreshTokenExpired Exception 발생시킴 -> 다시 로그인하도록 리다이렉트

0개의 댓글