Spring Security, JWT

보트·2023년 6월 27일
0

Spring

목록 보기
8/27

Spring Security

  • Spring Security 프레임워크는 Spring 서버에 필요한 인증 및 인가를 위한 기능 제공

  • Spring MVC 기반 어플리케이션에 보안을 적용하기 위한 표준

  • Interceptor나 Servlet Filter를 직접 구현하지 않아도 됨

  • UserDetailsservice와 UserDetails를 직접 구현해서 사용하게 되면 Security의 default로그인 기능을 사용하지 않겠다는 의미
    -> Security의 password 제공x

JwtAuthorizationFilter.java

@Slf4j(topic = "인가 필터")
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

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

        String accessToken = jwtUtil.getJwtFromCookie(request);

        if (StringUtils.hasText(accessToken)) {
            accessToken = jwtUtil.substringToken(accessToken);
            log.info("액세스 토큰 값 : " + accessToken);

            String newAccessToken = jwtUtil.reissueAccessToken(accessToken);
            if (newAccessToken != null) {
                jwtUtil.addJwtToCookie(newAccessToken, response);
            }

            if (!jwtUtil.validateToken(accessToken)) {
                log.info("액세스 토큰 유효하지 않음");
                return;
            }

            log.info("body의 사용자 정보 꺼내기");
            Claims info = jwtUtil.getUserInfoFromToken(accessToken);

            try {
                // token 생성 시 subject에 username 넣어둠
                log.info(info.getSubject());
                setAuthentication(info.getSubject());
            } catch (Exception e) {
                log.info("오류 발생");
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    // token -> authentication 객체에 담기 -> SecurityContext에 담기 -> ContextHolder에 담기
    // 인증 처리
    public void setAuthentication(String username) {
        log.info("인증 성공");
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(username);
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);
    }

    // 인증 객체 생성 (아직 인증 전)
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

}

UserDetailsImpl.java

public class UserDetailsImpl implements UserDetails {

    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

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

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

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

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

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

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

UserDetails 인터페이스를 구현한 클래스
Spring Security에서 관리하는 User 정보 관리
권한은 사용하지 않았음

UserDetailsServiceImpl.java

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + "는 존재하지 않는 사용자입니다."));

        return new UserDetailsImpl(user);
    }
}

UserDetailsService 인터페이스를 구현한 클래스
Spring Security에서 인증 정보를 조회하기 위해 사용

JWT(Json Web Token)

  • Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web token

  • JWT를 사용하면 로그인 정보를 클라이언트에 암호화하여 저장하는 것

  • 동시 접속자가 많을 때 서버 측 부하 낮춤

  • 세션 기반 인증 방식은 사용자의 로그인 정보를 서버에서 관리해야하기 때문에 서버에 부담

  • JWT 에 담는 내용이 커질수록 네트워크 비용 증가(클 -> 서)

JwtUtil.java

@Component
@RequiredArgsConstructor
@Slf4j(topic = "JwtUtil")
public class JwtUtil {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
    public static final long REFRESH_TOKEN_TIME = 7 * 24 * 60 * 60 * 1000L; // 7일

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey(application.properties)
    private String secretKey;
    private Key key;
    // 사용할 암호화 알고리즘
    public final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    private final RedisUtil redisUtil;

    @PostConstruct
    public void init() {
        // secretKey : 이미 base64로 인코딩 된 값
        // 사용하려면 디코딩
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // access token 생성
    public String createToken(String username) {
        log.info(username + "의 액세스 토큰 생성");
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username) // 사용자 id
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급 시간
                        .signWith(key, signatureAlgorithm) // 키, 암호화 알고리즘
                        .compact(); // 완성
    }

    // refresh token 생성
    public String createRefreshToken() {
        log.info("리프레시 토큰 생성");
        Date date = new Date();

        return Jwts.builder()
                .setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
                .signWith(key, signatureAlgorithm)
                .compact();
    }

    // access token 재발급
    public String reissueAccessToken(String token) {
        log.info("액세스 토큰 재발급");
        if (validateToken(token)) {
            Claims info = getUserInfoFromToken(token);
            String username = info.getSubject();
            log.info("재발급 요청자 : " + username);

            // refresh token 가져오기
            String refreshToken = redisUtil.getRefreshToken(username);

            // refresh token 존재하고 유효하다면
            if (StringUtils.hasText(refreshToken) && validateToken(refreshToken)) {
                log.info("리프레시 토큰 존재하고 유효함");
                return createToken(username);
            }
        }
        return null;
    }

    // 토큰 쿠키에 담기
    public void addJwtToCookie(String token, HttpServletResponse response) {
        log.info("토큰 쿠키에 담기");
        token = URLEncoder.encode(token, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
        log.info("token = " + token);
        Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
        cookie.setPath("/");

        // Response 객체에 Cookie 추가
        response.addCookie(cookie);
    }

    // Cookie에서 토큰 가져오기
    public String getJwtFromCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    log.info("쿠키에서 토큰 꺼내기" + URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8));
                    return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8); // Encode 되어 넘어간 Value 다시 Decode
                }
            }
        }
        return null;
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            if (redisUtil.isBlackList(token)) {
                // blacklist에 존재하는 access token이면
                throw new IllegalArgumentException("로그아웃된 토큰입니다");
            }
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            throw new IllegalArgumentException("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료");
            return false;
        } catch (UnsupportedJwtException e) {
            throw new IllegalArgumentException("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
    }

    public String substringToken(String token) {
        if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
            return token.substring(7);
        }
        throw new NullPointerException("token 비었거나 bearer로 시작하지 않습니다.");
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 액세스 토큰 남은 만료시간 계산
    public Long remainExpireTime(String token) {
        // 토큰 만료 시간
        Long expirationTime = getUserInfoFromToken(token).getExpiration().getTime();
        // 현재 시간
        Long dateTime = new Date().getTime();

        return expirationTime - dateTime;
    }

}

Jwt 생성, 검증 담당

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

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

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf((csrf) -> csrf.disable());

        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
                        .requestMatchers("/api/auth/**").permitAll() // '/api/auth/'로 시작하는 요청 모두 접근 허가
                        .requestMatchers(HttpMethod.GET,"/api/**").permitAll()
                        .requestMatchers(HttpMethod.GET,"/view/**").permitAll()//view페이지 모두 허용
                        .anyRequest().authenticated() // 그 외 모든 요청 인증처리
        );

        // 필터 관리
        http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

}

SecurityFilterChain의

  • http.csrf((csrf) -> csrf.disable());
    : Jwt 사용할 것이기 때문에 disable
profile
일주일에 한 번

0개의 댓글