Spring Security는 Spring 기반의 애플리케이션의 보안(인증, 권한, 인가 등)을 담당하는 Spring 하위의 프레임워크다.
블로그의 글 작성, 수정, 공개여부 변경 등의 기능은 관리자만 가능하기 때문에 Spring Security 를 사용해 해당 기능에 필요한 권한을 설정할 것이다.
또한, Spring boot 3.1.1 버전을 사용하고 있고 Spring Security 6 버전을 사용하기 때문에 유지보수의 편의를 위해 Spring Security 7 버전에서는 사용 불가능한 코드는 사용하지 않았다.
우선 프로젝트 하위에 security
디렉토리를 생성하였다.
security 디렉토리 안에 CustomUserDetailsService
, JwtAuthenticationFilter
, JwtProvider
, SecurityConfig
, TokenInfo
클래스들을 구현할 것이다.
@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();
}
}
@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;
}
}
@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();
}
}
}
해당 클래스에선 권한에 맞게 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();
}
}
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
private String grantType;
private String accessToken;
private String refreshToken;
}