[Mlog] 나만의 블로그 만들기 - Spring Security 적용

노의빈·2023년 10월 16일
0

Mlog

목록 보기
5/13

📌 개요

Spring Security는 Spring 기반의 애플리케이션의 보안(인증, 권한, 인가 등)을 담당하는 Spring 하위의 프레임워크다.

블로그의 글 작성, 수정, 공개여부 변경 등의 기능은 관리자만 가능하기 때문에 Spring Security 를 사용해 해당 기능에 필요한 권한을 설정할 것이다.

또한, Spring boot 3.1.1 버전을 사용하고 있고 Spring Security 6 버전을 사용하기 때문에 유지보수의 편의를 위해 Spring Security 7 버전에서는 사용 불가능한 코드는 사용하지 않았다.

📌 Spring Security 적용

우선 프로젝트 하위에 security 디렉토리를 생성하였다.
security 디렉토리 안에 CustomUserDetailsService, JwtAuthenticationFilter, JwtProvider, SecurityConfig, TokenInfo 클래스들을 구현할 것이다.

CustomUserDetailsService

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
    private final AdminRepository adminRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return adminRepository.findById(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,"입력된 정보와 일치하는 사용자가 없습니다."));
    }

    private UserDetails createUserDetails(Admin admin) {
        return User.builder()
                .username(admin.getUsername())
                .password(passwordEncoder.encode(admin.getPassword()))
                .roles(admin.getRoles().toArray(new String[0]))
                .build();
    }
}

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtProvider jwtProvider;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        /**
         * Request Header 에서 JWT 추출
         * */
        String token = resolveToken((HttpServletRequest) request);

        /**
         * validateToken 으로 토큰 유효성 검사
         * */
        if (token != null && jwtProvider.validateToken(token)) {
            // 토큰이 유효할 경우
            // 토큰에서 Authentication 객체를 가져와 SecurityContext 에 저장
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }


    /**
     * Request Header 에서 토큰 정보를 추출하는 함수
     * */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JwtProvider

@Slf4j
@Component
public class JwtProvider {
    private final Key key;

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

    /**
     * 유저 정보를 갖고 엑세트 토큰, 리프레시 토큰을 생성하는 함수
     * */
    public TokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        // 토큰 만료 시간 설정
        Date accessTokenExpiresIn = new Date((new Date()).getTime() + 1000L * 60 * 60 * 3);

        // 엑세스 토큰 생성
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // 리프레시 토큰 생성
        String refreshToken = Jwts.builder()
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    /**
     * JWT 를 복호화하여 토큰에 있는 정보를 꺼내는 함수
     * */
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }
        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    /**
     * 토큰 정보를 검증하는 함수
     * */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

SecurityConfig

해당 클래스에선 권한에 맞게 API를 호출할 수 있도록 설정하였다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(httpSecurityHttpBasicConfigurer -> {
                    httpSecurityHttpBasicConfigurer.disable();
                })
                .csrf(httpSecurityCsrfConfigurer -> {
                    httpSecurityCsrfConfigurer.disable();
                })
                .sessionManagement(httpSecuritySessionManagementConfigurer -> {
                    httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
                .logout(httpSecurityLogoutConfigurer -> {
                    httpSecurityLogoutConfigurer
                            .logoutUrl("/api/admin/logout")
                            .logoutSuccessHandler(((request, response, authentication) -> {
                                 response.setStatus(HttpStatus.OK.value());
                            }))
                            .deleteCookies("jwt")
                            .permitAll();
                })
                .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
                            authorizationManagerRequestMatcherRegistry
                                    // 모든 사용자 가능
                                    .requestMatchers(HttpMethod.GET, "/api/post", "/api/post/**", "/api/project", "/api/project/**", "/api/files/**").permitAll()
                                    .requestMatchers(HttpMethod.POST, "/api/admin/login").permitAll()
                                    // 관리자 로그인한 사용자만 가능
                                    .requestMatchers(HttpMethod.GET).hasAnyRole("ADMIN")
                                    .requestMatchers(HttpMethod.DELETE).hasAnyRole("ADMIN")
                                    .requestMatchers(HttpMethod.POST).hasAnyRole("ADMIN")
                                    .requestMatchers(HttpMethod.PUT).hasAnyRole("ADMIN")
                                    .anyRequest().authenticated();
                })
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    /**
     * 비밀번호 암호화 알고리즘 설정
     * */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

TokenInfo

@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
    private String grantType;
    private String accessToken;
    private String refreshToken;
}
profile
백엔드 공부 중입니다.

0개의 댓글