Spring Security + JWT + Mybatis 인증/인가 구현

2024. 10. 8. 16:21·Java/Spring Boot

✅ 준비물

이번 포스팅에서 진행되는 프로젝트의 사항에 대해서 먼저 알리고 시작하려고 한다.

포스팅을 보시다 별도로 만든 클래스가 나온다면 검색을 해주세요
제가 작성한 클래스는 웬만하면 본 포스팅에 적어뒀습니다.
만약 본 포스팅에 존재하지 않는다면 댓글 남겨주시면 최대한 빠르게 알려드리겠습니다.

 

🟨 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 버전

 

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();
    }
}

 

 

  1. authorizeHttpRequests에서 허용할 url과 권한을 부여할 url을 지정해 준다.
  2. addFilterBefore을 통해 Jwt인증필터와 Exception 필터를 지정해 준다.
  3. 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' 카테고리의 다른 글

QR Code 만들기  (0) 2025.03.07
Spring boot @RestController 유효성 검사  (0) 2024.09.22
HttpSessionListener를 이용한 중복 로그인 방지  (0) 2024.04.30
Bean Validation  (1) 2024.02.26
Thymeleaf 스프링 통합과 폼  (0) 2024.02.15
'Java/Spring Boot' 카테고리의 다른 글
  • QR Code 만들기
  • Spring boot @RestController 유효성 검사
  • HttpSessionListener를 이용한 중복 로그인 방지
  • Bean Validation
요술공주밍키
요술공주밍키
조금씩이라도 꾸준히..
  • 요술공주밍키
    삽질의흔적
    요술공주밍키
  • 전체
    오늘
    어제
    • 분류 전체보기 (139)
      • Java (42)
        • Spring Boot (14)
        • Spring Boot 게시판 (14)
        • 공중화장실 찾기 (4)
        • 쇼핑몰 (8)
      • JavaScript (8)
        • NodeJS (2)
      • Python (5)
        • Django (4)
      • Server (10)
        • Docker (4)
        • K8S (0)
        • Jenkins (1)
      • 알고리즘 (24)
        • 프로그래머스 (19)
        • 백준 (5)
      • Etc (21)
        • 개발 팁 (1)
      • 일상 (27)
        • 독서 포스트 (25)
        • 회고록 (2)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.1
요술공주밍키
Spring Security + JWT + Mybatis 인증/인가 구현
상단으로

티스토리툴바