✅ 준비물
이번 포스팅에서 진행되는 프로젝트의 사항에 대해서 먼저 알리고 시작하려고 한다.
포스팅을 보시다 별도로 만든 클래스가 나온다면 검색을 해주세요
제가 작성한 클래스는 웬만하면 본 포스팅에 적어뒀습니다.
만약 본 포스팅에 존재하지 않는다면 댓글 남겨주시면 최대한 빠르게 알려드리겠습니다.
🟨 Spring boot 버전
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6'
}
🟨 Java 버전
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
🟨 Jwt 버전
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
🟨 Security 버전
Security는 6점대 버전을 사용했다.
✅ SecurityConfig
우선 로직의 흐름대로 코드를 작성하도록 하겠다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final String[] allowedUrls = {"/swagger-ui/**", "/v3/**", "/api/hrInformationManagement/login"};
private final TokenProvider tokenProvider;
private final JwtExceptionFilter jwtExceptionFilter;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
.formLogin(auth -> auth.disable()) // Form 로그인 비활성화
.httpBasic(auth -> auth.disable()) // http basic 인증 방식 비활성화
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())) // X-Frame-Options 설정
// 경로 작업
.authorizeHttpRequests(request ->
request.requestMatchers(allowedUrls).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class)
.exceptionHandling(handling ->
handling
.accessDeniedHandler(jwtAccessDeniedHandler)
)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // Session 사용하지 않음
return http.build();
}
}
- authorizeHttpRequests에서 허용할 url과 권한을 부여할 url을 지정해 준다.
- addFilterBefore을 통해 Jwt인증필터와 Exception 필터를 지정해 준다.
- exceptionHandling을 통해 권한이 맞지 않을 경우 에러를 반환한다. AuthenticationEntryPoint도 있지만 인증의 경우 자세하게 에러를 반환하고 싶기 때문에 ExceptionFilter를 따로 구현하였다.
✅ JwtAuthenticationFilter
클라이언트가 애플리케이션에 접근했을 때 제일 먼저 해당 필터를 통해 토큰의 정보를 확인한다.
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final TokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
// Swagger나 로그인 등 특정 URL에 대해 필터 제외
String requestURI = httpServletRequest.getRequestURI();
if (requestURI.startsWith("/swagger") || requestURI.startsWith("/v3") || requestURI.startsWith("/api/hrInformationManagement/login")) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
// 헤더에서 토큰 받아오기
String token = tokenProvider.resolveToken((HttpServletRequest) servletRequest);
// 토큰이 유효하다면
if (token != null && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
else {
ObjectMapper objectMapper = new ObjectMapper();
Response<?> result = Response.builder()
.status(HttpStatus.UNAUTHORIZED.value())
.message("토큰이 존재하지 않습니다.")
.data(null)
.build();
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.getWriter().write(objectMapper.writeValueAsString(result));
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
여기서 Response<?>의 경우는 모든 API 반환값을 동일하게 유지하기 위해서 개인적으로 만든 DTO다.
builder 패턴을 보면 대충 어떤 식으로 만들었는지 알 테니 넘어간다.
✅ TokenProvider
실질적으로 토큰을 생성하고 인증 정보를 판단하는 클래스다.
@Slf4j
@Component
public class TokenProvider {
private final Key secretKey;
private final long tokenValidTime;
private final UserDetailsService userDetailsService;
public TokenProvider(@Value("${jwt.secret}") String secretKey, @Value("${jwt.expiration}") long expiration, UserDetailsService userDetailsService) {
this.secretKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName());
this.tokenValidTime = expiration;
this.userDetailsService = userDetailsService;
}
public String createToken(String userId, String role) {
Claims claims = Jwts.claims().setSubject(userId);
claims.put("role", role);
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효시간 설정
.signWith(secretKey, SignatureAlgorithm.HS256) // Key 객체와 알고리즘을 함께 전달
.compact();
}
// 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// 토큰을 검증하는 역할
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
return true;
} catch(io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.error("잘못된 JWT 서명입니다.");
throw new JwtException("잘못된 JWT 서명입니다.");
} catch(ExpiredJwtException e) {
log.error("만료된 JWT 토큰입니다.");
throw new JwtException("만료된 JWT 토큰입니다.");
} catch(UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰입니다.");
throw new JwtException("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다.");
throw new JwtException("JWT 토큰이 잘못되었습니다.");
}
}
// Request의 Header에서 token 값 가져오기
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7).trim();
}
return null;
}
}
TokenProvider의 생성자에 @Value를 통해 값을 가져와 대입해 준다.
@Value 또한 application.yml 등에서 데이터를 가져오는 것이니 다 안다고 생각하고 넘어간다.
✅ Custom
이번에는 TokenProvider에서 사용하는 UserDetailsService를 사용하기 위하여 Custom한 클래스를 생성한다.
🟨 CustomUserDetails
@Getter
@Builder
public class CustomUserDetails implements UserDetails {
private final String USER_ID;
private final String USER_PASSWORD;
private final Collection<SimpleGrantedAuthority> role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return role;
}
@Override
public String getPassword() {
return "";
}
@Override
public String getUsername() {
return String.valueOf(this.USER_ID);
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
🟨 CustomUserDetailsService
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final HrInformationManagementMapper hrInformationManagementMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Map<String, Object> user = hrInformationManagementMapper.selectUserByToken(username);
if (user == null) {
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
}
// 권한 설정 (SimpleGrantedAuthority를 사용)
String role = user.get("ADMIN_TYPE").toString().equals("Y") ? "ROLE_ADMIN" : "ROLE_USER";
Collection<SimpleGrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(role));
CustomUserDetails result = CustomUserDetails.builder()
.USER_ID(user.get("USER_ID").toString())
.USER_PASSWORD(user.get("USER_PASSWORD").toString())
.role(authorities)
.build();
return result;
}
}
필자의 경우 mybatis를 사용했기 때문에 JPA가 아닌 mapper를 통해 데이터를 가져왔고, 권한을 "ROLE_***" 형식으로 넣어야 하기 때문에 위와 같이 살짝 변형하였다.
✅ Exception
마지막으로 Exception이 발생했을 때 클라이언트에게 통일된 값을 반환하기 위하여 Exception 처리를 해준다.
🟨 JwtAccessDeniedHandler
@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Response<?> result = Response.builder()
.status(HttpStatus.FORBIDDEN.value())
.message("해당 리소스에 접근할 권한이 없습니다.")
.data(null)
.build();
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("utf-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE); // application/json
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
🟨 JwtExceptionFilter
@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
Response<?> result = Response.builder()
.status(HttpStatus.UNAUTHORIZED.value())
.message(e.getMessage())
.data(null)
.build();
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
}
JwtExceptionFilter의 경우는 TokenProvider에서 validateToken 메서드에서 오류가 발생해 JwtException을 throw하면
해당 필터에서 ecxeption을 잡아 처리한다.
✅ 테스트
🟨 토큰이 없을 때
우선 토큰이 존재하지 않을 경우 API를 호출해도 위와 같이 에러를 알려준다.
🟨 로그인 했을 때
로그인을 하면 위와 같이 User에 대한 정보와 Token 값을 넘겨준다.
🟨 권한이 없을 때
권한이 없을 때도 정상적으로 권한이 없다는 오류를 반환한다.
🟨 토큰이 잘못됐을 때
토큰 값이 정상적이지 않을 경우 또한 제대로 오류를 반환한다.
끝.
'Java > Spring Boot' 카테고리의 다른 글
Spring boot @RestController 유효성 검사 (0) | 2024.09.22 |
---|---|
HttpSessionListener를 이용한 중복 로그인 방지 (0) | 2024.04.30 |
Bean Validation (1) | 2024.02.26 |
Thymeleaf 스프링 통합과 폼 (0) | 2024.02.15 |
Ajax를 통해 파일과 Json 업로드 후 Controller로 받기 (0) | 2023.05.12 |