Spring/Security

Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 2

짱엉 2024. 8. 13. 11:38
728x90

개요

이번에는 JWT 인증 인가 부분을 구현할 예정이다.


 

 

 

1. JWT 생성 / 검증 과정에 해당하는 유틸리티 클래스

/**
 * JWT 생성 / 검증 과정에 해당하는 유틸리티 클래스
 */
@Component
@Slf4j
@Getter
public class JwtTokenizer {
    private final byte[] accessSecret;
    private final byte[] refreshSecret;

    public static Long ACCESS_TOKEN_EXPIRE_COUNT = 30 * 60 * 1000L; // 30분
    public static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // 7일

    // .yml 파일에 secretKey, refreshKey 값이 있어야 한다.
    public JwtTokenizer(@Value("${jwt.secretKey}") String accessSecret,
                        @Value("${jwt.refreshKey}") String refreshSecret){
        this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
        this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
    }

    /**
     * AccessToken 생성
     */
    public String createAccessToken(Long id, String username, String name, List<String> roles) {
        return createToken(id, username, name, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret);
    }

    /**
     * RefreshToken 생성
     */
    public String createRefreshToken(Long id, String username, String name, List<String> roles) {
        return createToken(id, username, name, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret);
    }

    /**
     * Jwts 빌더를 사용하여 token 생성
     */
    private String createToken(Long id, String username, String name, List<String> roles, long expireCount, byte[] secret) {
        Claims claims = Jwts.claims().setSubject(username); // 기본으로 가지고 있는 claim : subject
        claims.put("userId", id);
        claims.put("name", name);
        claims.put("roles", roles);

        Date now = new Date();
        Date expiration = new Date(now.getTime() + expireCount);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, getSigningKey(secret))
                .compact();
    }

    //----------------------------------------------------------------------------------추가-------------------------------//
    /**
     * JWT 토큰에서 사용자 ID 추출
     * @param token JWT 토큰
     * @return 사용자 ID
     */
    public Long getUserIdFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.get("userId", Long.class);
    }

    /**
     * JWT 토큰에서 사용자 이름 추출
     * @param token JWT 토큰
     * @return 사용자 이름
     */
    public String getUsernameFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.getSubject();
    }

    /**
     * JWT 토큰에서 역할 정보 추출
     * @param token JWT 토큰
     * @return 역할 목록
     */
    public List<String> getRolesFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.get("roles", List.class);
    }

//---------------------------------------------------------------------------------------------------------------------//
    /**
     * access token 파싱
     * @param accessToken access token
     * @return 파싱된 토큰
     */
    public Claims parseAccessToken(String accessToken) {
        return parseToken(accessToken, accessSecret);
    }

    /**
     * refresh token 파싱
     * @param refreshToken refresh token
     * @return 파싱된 토큰
     */
    public Claims parseRefreshToken(String refreshToken) {
        return parseToken(refreshToken, refreshSecret);
    }

    /**
     * token 파싱
     * @param token access/refresh token
     * @param secretKey access/refresh 비밀키
     * @return 파싱된 토큰
     */
    public Claims parseToken(String token, byte[] secretKey) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey(secretKey))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * @param secretKey - byte형식
     * @return Key 형식 시크릿 키
     */
    public static Key getSigningKey(byte[] secretKey) {
        return Keys.hmacShaKeyFor(secretKey);
    }

    /**
     * 토큰이 만료되었는지 확인하는 메서드
     */
    public boolean isTokenExpired(String token, byte[] secretKey) {
        try {
            Jwts.parserBuilder().setSigningKey(getSigningKey(secretKey)).build().parseClaimsJws(token).getBody();
            return false;
        } catch (io.jsonwebtoken.ExpiredJwtException e) {
            return true;
        }
    }

    /**
     * 액세스 토큰이 만료되었는지 확인
     */
    public boolean isAccessTokenExpired(String accessToken) {
        return isTokenExpired(accessToken, accessSecret);
    }

    /**
     * 리프레시 토큰이 만료되었는지 확인
     */
    public boolean isRefreshTokenExpired(String refreshToken) {
        return isTokenExpired(refreshToken, refreshSecret);
    }

    /**
     * 리프레시 토큰으로 새로운 액세스 토큰을 발급
     */
    public String refreshAccessToken(String refreshToken) {
        Claims claims = parseRefreshToken(refreshToken);
        Long userId = claims.get("userId", Long.class);

        String username = claims.getSubject(); // 토큰의 주체
        String name = claims.get("name", String.class);
        List<String> roles = (List<String>) claims.get("roles");
        return createAccessToken(userId, username, name, roles);
    }
}

로그인할 때 필요한 username으로 sub 만들었고, userId, name, roles을 페이로드에 추가하였다.

 

 

2. JWT 인증 토큰 클래스

/**
 * JWT 인증 토큰 클래스
 */
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private String token;       // JWT 토큰
    private Object principal;   // 주체 (사용자)
    private Object credentials; // 인증 정보 (자격 증명)

    /**
     * 인증된 토큰을 생성하는 생성자
     * @param authorities   권한
     * @param principal     주체 (사용자)
     * @param credentials   인증 정보 (자격 증명)
     */
    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities , Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(true); // 토큰을 인증된 상태로 설정
    }

    /**
     * 인증되지 않은 토큰을 생성하는 생성자
     * @param token JWT 토큰
     */
    public JwtAuthenticationToken(String token){
        super(null);
        this.token = token;
        this.setAuthenticated(false); // 토큰을 인증되지 않은 상태로 설정
    }

    /**
     * 자격 증명을 반환
     * @return 자격 증명
     */
    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    /**
     * 주체 (사용자)를 반환
     * @return 주체 (사용자)
     */
    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}

 

 

3. 오류 코드

@Getter
public enum JwtExceptionCode {

    UNKNOWN_ERROR("UNKNOWN_ERROR", "UNKNOWN_ERROR"),
    NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Cookie에 토큰 형식의 값 찾을 수 없음"),
    INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
    EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
    UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰"),
    BLACKLISTED_TOKEN("BLACKLISTED_TOKEN", "로그아웃하여 블랙리스트에 올라간 토큰");

    private String code;
    private String message;

    JwtExceptionCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
}
/**
 * 시큐리티가 인증되지 않은 사용자가 보호된 리소스에 접근할 때 동작하는 클래스
 */
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String)request.getAttribute("exception");

        if(isRestRequest(request)){
            handleRestResponse(request, response, exception);
        } else {
            handlePageResponse(request, response, exception);
        }
    }

    private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        if (exception != null) {
            switch (exception) {
                case "INVALID_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "EXPIRED_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "UNSUPPORTED_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "NOT_FOUND_TOKEN":
                    response.sendRedirect("/");
                    break;
                default:
                    response.sendRedirect("/");
                    break;
            }
            return;
        }
        response.sendRedirect("/");
    }

    private boolean isRestRequest(HttpServletRequest request) {
        String requestedWithHeader = request.getHeader("X-Requested-With");
        return "XMLHttpRequest".equals(requestedWithHeader) || request.getRequestURI().startsWith("/api/");
    }

    private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {

        if (exception != null) {
            log.error("Rest Request - Commence Get Exception : {}", exception);
            if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
                log.error("entry point >> invalid token");
                setResponse(response, JwtExceptionCode.INVALID_TOKEN);
            } else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
                log.error("entry point >> expired token");
                setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
                log.error("entry point >> unsupported token");
                setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
                log.error("entry point >> not found token");
                setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
            } else {
                setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
            }
        } else {
            setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
        }
        response.sendRedirect("/");
    }

    private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        HashMap<String, Object> errorInfo = new HashMap<>();
        errorInfo.put("message", exceptionCode.getMessage());
        errorInfo.put("code", exceptionCode.getCode());
        Gson gson = new Gson();
        String responseJson = gson.toJson(errorInfo);
        response.getWriter().print(responseJson);
    }
}

인증되지 않은 접근일 경우 CustomAuthenticationEntryPoint 를 작성하여 오류에 대해서 다뤘다.

 

 

 

4. 필터

/**
 * 요청이 들어올 때마다 JWT 토큰을 검증하는 필터
 * 토큰을 검증하고 유효한 사용자라면 그 사용자의 정보를 SecurityContextHolder 에 설정
 */
@Slf4j
public class JWTFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;

    public JWTFilter(JwtTokenizer jwtTokenizer, JwtBlacklistService jwtBlacklistService, RefreshTokenService refreshTokenService) {
        this.jwtTokenizer = jwtTokenizer;
        this.jwtBlacklistService = jwtBlacklistService;
        this.refreshTokenService = refreshTokenService;
    }

    /**
     * 접근 허용 앤드 포인트 목록
     */
    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/signup", "/signin", "/", "/api/users/login", "/api/users/signup",
            "/css/.*", "/js/.*", "/files/.*"
    );

    /**
     * 필터 메서드
     * 각 요청마다 JWT 토큰을 검증하고 인증을 설정
     * @param request       요청 객체
     * @param response      응답 객체
     * @param filterChain   필터 체인
     * @throws ServletException 서블릿 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestPath = request.getRequestURI(); // 요청 경로 확인
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = getToken(request); // 요청에서 토큰을 추출

        if (!StringUtils.hasText(token)) { // 토큰을 사용하여 인증 설정
            handleMissingToken(request, response);
        } else {
            handleTokenValidation(request, response, token);
        }
        filterChain.doFilter(request, response); // 다음 필터로 요청을 전달
        // log.info("토큰 제대로?? : {}", token);
    }

    /**
     *  요청 경로가 인증 없이 접근 가능한지 확인
     */
    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    /**
     * Access Token이 없는 경우 처리하는 메서드
     */
    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 쿠키에서 Refresh Token을 얻어옴
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
                // Refresh Token이 DB에 존재하는지 확인
                if (refreshTokenService.isRefreshTokenValid(refreshToken)) {
                    if (!jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
                        String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
                        setAccessTokenCookie(response, newAccessToken);
                        getAuthentication(newAccessToken);
                    } else {
                        // Refresh Token이 만료된 경우
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    // Refresh Token이 DB에 없는 경우
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                // Refresh Token이 만료된 경우
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }

    /**
     * Access Token이 있는 경우 처리하는 메서드
     */
    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            // 토큰이 블랙리스트에 있는지 확인
            if (jwtBlacklistService.isTokenBlacklisted(token)) {
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    /**
     * Access Token이 만료된 경우 처리하는 메서드
     */
    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
            // Refresh Token이 유효한 경우 새로운 Access Token 발급
            String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);

            setAccessTokenCookie(response, newAccessToken);

            getAuthentication(newAccessToken);
        } else {
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    /**
     * 요청에서 토큰 추출
     */
    private String getToken(HttpServletRequest request) {

        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }

        Cookie[] cookies = request.getCookies(); // 쿠키에서 토큰을 찾음
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue(); // accessToken 쿠키에서 토큰 반환
                }
            }
        }
        return null; // 토큰을 찾지 못한 경우 null 반환
    }

    /**
     * 쿠키에서 Refresh Token을 추출하는 메서드
     */
    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    /**
     * 새로운 Access Token을 쿠키에 설정하는 메서드
     */
    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true); // XSS 보호를 위해 HttpOnly 설정
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    /**
     * 토큰을 사용하여 인증 설정
     */
    private void getAuthentication(String token) {
        Claims claims = jwtTokenizer.parseAccessToken(token); // 토큰에서 클레임을 파싱
        String username = claims.getSubject(); // 이메일을 가져옴
        Long userId = claims.get("userId", Long.class);  // 사용자 ID를 가져옴
        String name = claims.get("name", String.class); // 이름을 가져옴
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims); // 사용자 권한을 가져옴

        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);

        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null); // 인증 객체 생성

        SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContextHolder에 인증 객체 설정

    }

    /**
     * JWT Claims에서 권한 정보를 추출하는 메서드
     */
    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    /**
     * 예외 처리 메서드
     */
    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
//        throw new BadCredentialsException(logMessage, e);
        log.error("로그인을 부탁드립니다.");
    }
}

로그인이 들어올 때 기존 시큐리티 로그인 처리 필터인 UsernamePasswordAuthenticationFilter 이전에 실행되어서 토큰을 발급한 후 로그인 처리를 진행한다.

 

 

5. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;
    private final JwtTokenizer jwtTokenizer;
    private final CustomOauth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",        // 메인페이지
            "/signup", // 회원가입 페이지
            "/signin", // 로그인 페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/users/login", // 로그인 페이지
            "/api/users/signup" // 회원가입 페이지
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JWTFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class) // JWT 필터 사용
                .formLogin(form -> form.disable()) // 로그인 폼 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 Stateless 설정(서버가 클라이언트 상태 저장x)
                .csrf(csrf -> csrf.disable()) // cors 허용
                .httpBasic(httpBasic -> httpBasic.disable()) // http 기본 인증(헤더) 비활성화
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                )
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/signin")
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureUrl("/loginFailure")
                        .authorizationEndpoint(authorization -> authorization.baseUri("/oauth2/authorization"))
                        .permitAll()
                );
        return http.build();
    }

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

    @Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*"); // 모든 도메인 허용
        config.addAllowedOrigin("http://localhost:3000"); // 프론트의 주소
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

위 코드에서도 보듯이 UsernamePasswordAuthenticationFilter 이전에 우리가 커스텀한 jwt 필터를 추가해줬다. 그리고 exceptionhandling에 대해서도 위에서 정의한 cusstomAuthenticationEntryPoint 를 지정했다.

 

 

6. UserDetails

/**
 * 사용자 인증 및 권한 관리를 위한 사용자 세부 정보 클래스
 */
public class CustomUserDetails implements UserDetails {

    private final String username; // 사용자 아이디
    private final String password; // 비밀번호
    private final Collection<? extends GrantedAuthority> authorities; // 권환

    // DB 에서 사용자 인증/인가
    public CustomUserDetails(String username, String password, Set<Role> roles) {
        this.username = username;
        this.password = password;
        this.authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    // jwt 인증 생성자
    public CustomUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다. 그래서 jwt 로그인 진행 시 인증 설정을 하고 securityContextHolder에 저장한다.

 

 

7. APIController

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/users")
public class UserApiController {

    private final JwtTokenizer jwtTokenizer;
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenService refreshTokenService;
    private final JwtBlacklistService jwtBlackListService;

    /**
     * 로그인 요청 시 jwt 토큰 발급
     */
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody @Valid UserLoginDto userLoginDto,
                                BindingResult bindingResult,
                                HttpServletResponse response){
        log.info("로그인 요청이 들어왔습니다.");
        log.info("아이디    :: {}", userLoginDto.getUsername());
        log.info("비밀번호 :: {}", userLoginDto.getPassword());

        if(bindingResult.hasErrors()){ // 필드 에러 확인
            return new ResponseEntity(HttpStatus.BAD_REQUEST);
        }

        Optional<User> user = userService.findByUsername(userLoginDto.getUsername());

        // 비밀번호 일치여부 체크
        if(!passwordEncoder.matches(userLoginDto.getPassword(), user.get().getPassword())) {
            return new ResponseEntity("비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED);
        }

        List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());

        refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));

        // 토큰 발급
        String accessToken = jwtTokenizer.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(),  roles);
        String refreshToken = jwtTokenizer.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);

        // 리프레시 토큰 디비 저장
        Date date = new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_COUNT);
        RefreshToken refreshTokenEntity = new RefreshToken();
        refreshTokenEntity.setValue(refreshToken);
        refreshTokenEntity.setUserId(user.get().getId());
        refreshTokenEntity.setExpiration(date.toString());

        refreshTokenService.addRefreshToken(refreshTokenEntity);

        // 토큰 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(REFRESH_TOKEN_EXPIRE_COUNT/1000));

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

        // 응답 값
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getId())
                .username(user.get().getUsername())
                .build();

        return new ResponseEntity(loginResponseDto, HttpStatus.OK);
    }

    /**
     * 로그아웃 요청
     */
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
                       @CookieValue(name = "refreshToken", required = false) String refreshToken,
                       HttpServletResponse response) {
        log.info("로그아웃 요청이 들어왔습니다.");
        if (accessToken == null) {

            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                e.printStackTrace();
            }
            return;
        }

        String jwt = accessToken;

        Date expirationTime = Jwts.parser()
                .setSigningKey(jwtTokenizer.getAccessSecret())
                .parseClaimsJws(jwt)
                .getBody()
                .getExpiration();

        log.info("accessToken 만료시간 :: {}" , expirationTime);

        JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
        jwtBlackListService.save(blacklist);

        SecurityContextHolder.clearContext();

        Cookie accessCookie = new Cookie("accessToken", null);
        accessCookie.setPath("/");
        accessCookie.setMaxAge(0);
        response.addCookie(accessCookie);

        Cookie refresCcookie = new Cookie("refreshToken", null);
        refresCcookie.setPath("/");
        refresCcookie.setMaxAge(0);
        response.addCookie(refresCcookie);

        // tokens 데이터 삭제
        refreshTokenService.deleteRefreshToken(refreshToken);

        try {
            response.sendRedirect("/");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 회원가입
     */
    @PostMapping("/signup")
    public ResponseEntity<String> signup(@RequestBody UserSignUpDto userSignUpDto) {
        try {
            userService.signUp(userSignUpDto);
            return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공");
        } catch (RuntimeException e) {
            log.error("회원가입 실패 :: {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("회원가입 실패 :: " + e.getMessage());
        }
    }

    /**
     * 회원 탈퇴
     */
    @DeleteMapping("/{userId}")
    public ResponseEntity<String> deleteUser(@PathVariable("userId") Long userId,
                                             @CookieValue(name = "accessToken", required = false) String accessToken,
                                             @CookieValue(name = "refreshToken", required = false) String refreshToken,
                                             HttpServletResponse response,
                                             Authentication authentication) {
        String username = authentication.getName();
        try {
            userService.deleteUser(username);

            // 로그아웃 로직
            if (accessToken != null) {
                // JWT 토큰 추출
                String jwt = accessToken;

                // 토큰의 만료 시간 추출
                Date expirationTime = Jwts.parser()
                        .setSigningKey(jwtTokenizer.getAccessSecret())
                        .parseClaimsJws(jwt)
                        .getBody()
                        .getExpiration();

                // 블랙리스트에 토큰 저장
                JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
                jwtBlackListService.save(blacklist);
            }

            // SecurityContext를 클리어하여 현재 세션을 무효화
            SecurityContextHolder.clearContext();

            // accessToken 쿠키 삭제
            Cookie accessCookie = new Cookie("accessToken", null);
            accessCookie.setPath("/");
            accessCookie.setMaxAge(0);
            response.addCookie(accessCookie);

            // refreshToken 쿠키 삭제
            Cookie refreshCookie = new Cookie("refreshToken", null);
            refreshCookie.setPath("/");
            refreshCookie.setMaxAge(0);
            response.addCookie(refreshCookie);

            // 로그아웃 전 db에 저장되어있는 refreshToken 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(refreshToken);
            }
            return ResponseEntity.ok("회원 탈퇴 성공 !!");
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("회원 탈퇴 실패 !!");
        }
    }
}

로그인, 로그아웃, 회원탈퇴 api요청이다. 유저가 로그인 요청을 받으면 엑세스, 리프레시 토큰이 생성되고 쿠키에 저장하는 로직이다. 

 

 

다음은 OAuth2 로그인에 대해서 작성하겠다.

728x90