SpringBoot Security 6 버전 인증 처리(+ JWT Token 사용)

devdo·2024년 11월 18일
0

SpringBoot

목록 보기
25/39
post-thumbnail

SpringBoot3 - Security 6버전으로 진행하면서 그전 버전인 Secuirty5 버전과 많이 달라지게 되었습니다.

우리는 SpringBoot3버전 Security 6 을 사용해서 JWT Token(accessToken & refreshToken)을 사용해서 인증처리를 할 겁니다.


토큰 기반 인증 방식(JWT)

Token 이란?
: 인증을 위해 사용되는 암호화된 문자열

  • 사용자가 인증에 성공하면 서버는 토큰을 생성해서 클라이언트로 보낸다.
  • 토큰도 세션과 마찬가지로 사용자가 보내는 요청에 포함 된다.
  • 세션 인증에서는 서버가 세션ID를 저장하고 클랑이언트가 쿠키에 실어보낸 세션ID와 대조해서 확인 하는 반면,
  • 토큰을 사용하면 요청을 받은 서버는 토큰이 유효한지를 확인만 함.
  • 세션 인증에 비해 서버 운영의 효율이 더 좋다.

세션/쿠키 방법과 유사하게 사용자는 Access Token(JWT토큰)을 HTTP헤더에 실어 서버로 보낸다.


JWT 토큰 만드는 법

토큰을 만들기 위해서는 크게 3가지, Header, Payload, Verify Signature가 필요합니다.

Header : 위 3가지 정보를 암호화할 방식(alg), 타입(type) 등
Payload : 서버에서 보낼 데이터. 일반적으로 유저의 고유 ID값, 유효기간
Verify Signature : Base64 방식으로 인코딩한 Header,payload 그리고 SECRET KEY를 더한 후 서명

Verify Signature는 SECRET KEY를 알지 못하면 복호화할 수 없다.

A 사용자가 토큰을 조작하여 B 사용자의 데이터를 훔치려고 한다고 가정해보자.

payload에 있던 A의 ID를 B의 ID로 바꿔서 다시 인코딩 후 토큰을 서버로 보낸다. 그러면 서버는 처음에 암호화 된 Verify Signature를 검사한다.

여기서 Payload는 B사용자의 정보가 들어 있으나 Verify Signature는 A의 Payload를 기반으로 암호화 되었기 때문에 유효하지 않는 토큰으로 간주하게 된다. 그래서 사용자는 SECRET KEY를 알지 못하는 이상 토큰을 조작할 수 없다는 것이다.


accessToken & refreshToken 흐름도

accessToken & refreshToken 흐름도를 이해해 봅시다.

Refresh 토큰을 통한 재인증 흐름은 다음과 같습니다:

1) 사용자 로그인: 사용자 인증이 성공하면 Access 토큰과 Refresh 토큰이 발급됩니다.

2) 서버가 로그인 성공한 사용자의 고유ID값을 부여한 후, Secuirty Context 에 저장합니다.

3), 4) 서버가 JWT의 유효기간 설정, 암호화할 SECRET KEY를 이용해 Access Token을 발급하고 응답해줍니다.

5) 이제 로그인 인증된 사용자는 서버가 준 Access Token을 Header에 실어 요청을 해줍니다.

6) 서버는 헤더에 실은 AccessToken을 filter에서 검증하고, 검증이 완료되면, 사용자 ID에 맞는 데이터를 응답해줍니다.


RefreshToken의 사용이유- AccessToken 재발급

Access 토큰 만료: Access 토큰이 만료되면, 클라이언트는 Refresh 토큰을 사용해 새로운 Access 토큰을 요청합니다.

토큰 갱신 요청: 서버는 DB에서 Refresh 토큰의 유효성을 확인한 후, 새로운 Access 토큰을 발급합니다. 필요시 Refresh 토큰도 갱신합니다.

기존의 Access Token의 유효기간을 짧게 하고 AccessToken이 만료가 되면, Refresh Token로 새로운 토큰을 발급해준다! Access Token을 탈취 당해도 상대적으로 보안이 강화된 셈!

그래도, refreshToken마저 탈취해서 문제가 생길 수 있다.

탈취 위험 감소: refreshToken이 탈취되면 이를 재사용하여 새로운 accessToken을 발급받아 공격자가 시스템에 접근할 수 있습니다. 데이터베이스에 저장하면 refreshToken이 유출되었을 때 이를 쉽게 무효화할 수 있습니다.

이러한 이유들로 인해 Refresh 토큰을 DB에 저장하는 것이 보안과 상태 유지 측면에서 유리합니다!

chatGPT에서 물어본 결과)

JWT refreshToken을 데이터베이스에 보관하는 것은 보안 측면에서 여러 가지 이점이 있습니다:

탈취 위험 감소: refreshToken이 탈취되면 이를 재사용하여 새로운 accessToken을 발급받아 공격자가 시스템에 접근할 수 있습니다. 데이터베이스에 저장하면 refreshToken이 유출되었을 때 이를 쉽게 무효화할 수 있습니다.

추적 및 관리: 데이터베이스에 저장된 refreshToken을 통해 누가 언제 refreshToken을 사용했는지 추적할 수 있습니다. 이를 통해 비정상적인 활동을 모니터링하고 필요한 경우 조치를 취할 수 있습니다.

유효성 검사: 데이터베이스를 통해 refreshToken의 유효성을 쉽게 검증할 수 있습니다. 사용된 refreshToken의 만료 시간을 확인하거나, 특정 사용자와 연결된 모든 refreshToken을 무효화하는 것이 가능합니다.

안전한 저장: 데이터베이스에 저장된 refreshToken은 안전하게 암호화된 상태로 보관될 수 있습니다. 이를 통해 refreshToken이 불필요하게 노출되지 않도록 보호할 수 있습니다.

로그아웃 구현: 사용자가 로그아웃하면 해당 사용자의 모든 refreshToken을 데이터베이스에서 삭제할 수 있어, 더 이상 새로운 accessToken이 발급되지 않도록 할 수 있습니다.

설정

일단, JWT 토큰을 사용할 거라서 외부 라이브러리를 가져와서 써야 합니다.

build.gradle

# 현재 버전은 security 6
implementation 'org.springframework.boot:spring-boot-starter-security'

# jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

코드

SecurityConfig

  • h2-console 설정을 필터에서 제외시킴.. 그리고 Xframe 설정을 해줘야 h2-console 사이트에 문제가 안생깁니다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    /**
     * 일반 경로는 모두 허용하고, h2-console 경로는 CSRF, X-Frame-Options 설정을 무시한다.
     * @param http HttpSecurity
     * @return SecurityFilterChain
     * @throws Exception 예외
     */

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorizedHttpRequests) -> authorizedHttpRequests
                        .requestMatchers(new AntPathRequestMatcher(("/**"))).permitAll()) // 모든 경로 허용
                .csrf(
                        csrf -> csrf
                                .ignoringRequestMatchers("/h2-console/**")
                )
                .headers(
                        headers -> headers
                                .addHeaderWriter(
                                        new XFrameOptionsHeaderWriter(
                                                XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN
                                        )
                                )
                );
        return http.build();
    }


    // passwordEncoder를 bean으로 등록하기
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

ApiSecurityConfig

  • API와 관련된 Security Config 파일을 따로 분리해서 관리해준다.

  • CORS 설정을 해준다. front 와 연동을 해줄려면.

  • api match시킬 때, new AntPathRequestMatcher 를 추가적으로 쓴 이유!
    endPoint 설정이 mvc patteren 인지 아닌지 구분하기 위해서 지정해주지 않으면 오류가 날때가 있기 때문에 명시적으로 작성해줘야 한다!

[Spring] This method cannot decide whether these patterns are Spring MVC patterns or not.


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class ApiSecurityConfig {


    private final JWTCheckFilter jwtCheckFilter;

    @Bean
    SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {

        http
                .securityMatcher("/api/**")  // api로 시작하는 모든 요청에 대해 시큐리티 필터 적용
                .authorizeHttpRequests(
                authorizeHttpRequests -> authorizeHttpRequests
                        // /favicon.ico 경로 제외 설정
//                        .requestMatchers(new AntPathRequestMatcher("/favicon.ico")).permitAll()
                        // h2-console 경로 제외 설정
                        .requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/api/member/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/api/products/view/*")).permitAll()
                        .anyRequest().authenticated()
        )
                .csrf(
                        csrf -> csrf.disable()
                ) // httpBasic 인증 방식 끄기
                .httpBasic(
                        httpBasic -> httpBasic.disable()
                ) // formLogin 인증 방식 끄기
                .formLogin(
                        formLogin -> formLogin.disable()
                ) // 세션 끄기
                .sessionManagement(
                        sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                ) // 필터 추가
                .addFilterBefore(
                        jwtCheckFilter, // 엑세스 토큰을 이용한 로그인 처리
                        UsernamePasswordAuthenticationFilter.class
                );

        http.cors(httpSecurityCorsConfigurer -> {
            httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource());
        });

        return http.build();
    }


	// CORS 설정 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {

        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000"));    // 허용할 출처
        configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));  // "Authorization", "Cache-Control", "Content-Type"
        configuration.setAllowCredentials(true);        // 쿠키를 주고 받을 수 있도록 설정

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

JWTCheckFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JWTCheckFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String path = request.getRequestURI();
        log.info("check uri: " + path);

        // Pre-flight 요청은 필터를 타지 않도록 설정
        if (request.getMethod().equals("OPTIONS")) {
            return true;
        }
        // /api/member/로 시작하는 요청은 필터를 타지 않도록 설정
        if (path.startsWith("/api/v1/members/")) {
            return true;
        }
        // /api/product/view로 시작하는 요청은 필터를 타지 않도록 설정
        if (path.startsWith("/api/v1/products/view/")) {
            return true;
        }

        // Swagger UI 경로 제외 설정
        if (path.startsWith("/swagger-ui/") || path.startsWith("/v3/api-docs")) {
            return true;
        }
        // h2-console 경로 제외 설정
        if (path.startsWith("/h2-console")) {
            return true;
        }

        return false;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("------------------JWTCheckFilter.................");
        log.info("request.getServletPath(): {}", request.getServletPath());
        log.info("..................................................");

//        String autHeaderStr = request.getHeader("Authorization");

        try {
            // Bearer accessToken 형태로 전달되므로 Bearer 제거
//            String accessToken = autHeaderStr.substring(7);// Bearer 제거
            String accessToken = CookieUtil.getTokenFromCookie(request, "accessToken");
            log.info("accessToken: {}", accessToken);

            if (!accessToken.isBlank()) {
                // 토큰 유효기간 검증

                // 로그인 처리
                Map<String, Object> claims = jwtUtil.validateToken(accessToken);

                log.info("JWT claims: {}", claims);

                String username = (String) claims.get("username");
                String email = (String) claims.get("email");
                String password = (String) claims.get("password");

                MemberDTO memberDTO = new MemberDTO(username, email, password);

                log.info("memberDTO: {}", memberDTO);

                // 인증 객체 생성
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(memberDTO, password, memberDTO.getAuthorities());

                // SecurityContextHolder에 인증 객체 저장
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);

            }
            // 통과 및 다음 필터로 이동
            filterChain.doFilter(request, response);

        } catch (Exception e) {
            log.error("JWT Check Error...........");
            log.error("e.getMessage(): {}", e.getMessage());

            Gson gson = new Gson();
            String msg = gson.toJson(Map.of("error", "ERROR_ACCESS_TOKEN"));

            response.setContentType("application/json;charset=UTF-8");
            PrintWriter printWriter = response.getWriter();
            printWriter.println(msg);
            printWriter.close();
        }


    }
}

JWTUtil

@Component
public class JWTUtil {

    @Value("${jwt.secret}")
    private String SECRET_KEY;

//    private static final long EXPIRATION_TIME = 24 * 60 * 60 * 1000; // mils

    public String generateToken(Map<String, Object> claims, long mils) {
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + mils))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public Claims validateToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

}

CookieUtil

  • 쿠키를 이용해서 jwt token을 넣어서 front 에 보내준다.
  • 쿠키를 사용하는 이유 : 보안이 더 뛰어남
public class CookieUtil {

    public static void setTokenCookie(HttpServletResponse response, String name, String value, long seconds) {
        ResponseCookie cookie = ResponseCookie.from(name, value)
                .path("/") // CORS 설정, 모든 경로에서 접근 가능
                .httpOnly(true) // XSS 방지, JS에서 접근 불가
                .secure(true)   // HTTPS, SSL 설정
                .sameSite("None")  // CORS 설정, 모든 도메인에서 접근 가능
                .maxAge(seconds) // maxAge 설정 (초)
                .build();

        response.addHeader("Set-Cookie", cookie.toString());
    }

    public static void removeTokenCookie(HttpServletResponse response, String token) {
        ResponseCookie cookie = ResponseCookie.from(token, "")
                .path("/")
                .httpOnly(true)
                .secure(true)
                .sameSite("None")
                .maxAge(0L)
                .build();

        response.addHeader("Set-Cookie", cookie.toString());
    }

    public static String getTokenFromCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(name))
                .findFirst()
                .map(Cookie::getValue)
                .orElse(null);
    }
}


MemberDTO

  • Authentication > UserDetails 를 구현한 User 를 상속한 MemberDTO를 만들어줍니다.
  • 클라이언트에 응답한 User 정보를 나타낼 Dto를 겸합니다.
@Getter
@Setter
@ToString
public class MemberDTO extends User {

    private String username;
    private String email;
    @JsonIgnore
    private String password;

    public MemberDTO(String username, String email, String password) {
        super(username, password, new ArrayList<>());
        this.username = username;
        this.email = email;
        this.password = password;
    }

    @JsonIgnore
    public Map<String, Object> getClaims() {
        Map<String, Object> dataMap = new HashMap<>();
        dataMap.put("username", this.username);
        dataMap.put("email", this.email);
        dataMap.put("password", this.password);
        return dataMap;
    }

}

Member

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Setter
@Getter
@Table(name = "tbl_member")
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String email;
    private String password;
	// refresh 토큰을 memeber 테이블에 두는 이유
    @Column(name = "refresh_token", length = 1000)
    private String refreshToken;

}

MemberV1Controller

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/members")
public class MemberV1Controller {

    private final MemberService memberService;


    @NoArgsConstructor  //Jackson이 JSON -> java 객체를 생성하기 위해 기본 생성자가 필요!
    @AllArgsConstructor
    @Getter
    @Setter
    public static class LoginRequest {
        @NotBlank
        private String username;
        @NotBlank
        private String password;
    }

    @Getter
    @AllArgsConstructor
    public static class LoginResponse {
        private MemberDTO memberDTO;
    }

    @PostMapping("/login")
    public RsData<LoginResponse> login(@Valid @RequestBody LoginRequest loginRequest, HttpServletResponse response) {

        // username, password를 이용 => access token 발급
        RsData<MemberServiceImpl.AuthAndAccessTokenResponse> authAndTokenRs =
                memberService.authAndIssueToken(loginRequest.getUsername(), loginRequest.getPassword());

        // access token, refreshToken 을 쿠키에 담아서 전달 (1day)
        CookieUtil.setTokenCookie(response, "accessToken", authAndTokenRs.getData().getAccessToken(), 24 * 60 * 60);
        CookieUtil.setTokenCookie(response, "refreshToken", authAndTokenRs.getData().getRefreshToken(), 24 * 60 * 60);

        return RsData.of(
                authAndTokenRs.getResultCode(),
                authAndTokenRs.getMsg(),
                new LoginResponse(authAndTokenRs.getData().getMemberDTO()));
    }


    @GetMapping("/me")
    public String me() {
        return "me";
    }

    // 로그아웃
    @PostMapping("/logout")
    public RsData<String> logout(HttpServletResponse response) {
        // access token, refreshToken 쿠키 삭제
        CookieUtil.removeTokenCookie(response, "accessToken");
        CookieUtil.removeTokenCookie(response, "refreshToken");

        return RsData.of("200", "logout success");
    }
}

CustomUserDetailService

  • 인증한 User 정보를 찾는loadUserByUsername 메서드가 있는 UserDetailsService 를 커스터마이징한 Service 클래스를 만들어줍니다.
@Service
@Log4j2
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        log.info("loadUserByUsername: username: {}", username);

        Member member = memberRepository.getWithRoles(username);
        if(member == null) {
            throw new UsernameNotFoundException("NOT EXIST! username: " + username);
        }

        MemberDTO memberDTO = new MemberDTO(
                member.getEmail(),
                member.getPw(),
                member.getNickname(),
                member.isSocial(),
                member.getMemberRoleList().stream().map(Enum::name).toList());

        log.info("memberDTO: {}", memberDTO);

        return memberDTO;
    }
}

@PreAuthorize를 통한 접근 권한 처리

특정 권한을 가진 사용자만이 접근할 수 있도록 제한하는 어노테이션입니다.

SecurityConfig 에 @EnableMethodSecurity 추가해주어야 합니다.


@EnableMethodSecurity  // 추가! @PreAuthorize, @Secured, @RolesAllowed 어노테이션을 사용하기 위해 필요
public class SecurityConfig {

Controller

 @PreAuthorize("hasAnyRole('ROLE_USER','ROLE_ADMIN')") //임시로 권한 설정
//  @PreAuthorize("hasAnyRole('ROLE_ADMIN')") //임시로 권한 설정
  @GetMapping("/list")
  public PageResponseDTO<ProductDTO> list(PageRequestDTO pageRequestDTO) {

    log.info("list............." + pageRequestDTO);

    return productService.getList(pageRequestDTO);

  }
}

JWTCheckFilter에서 JWT인증 정보를 이용해서 사용자 권한 검증을 합니다. -> JWTUtil.validateToken(String accessToken)



권한 접근 핸들러

그리고 SecurityConfig 에서 접근 권한 Exception Handler, CustomAccessDeniedHandler 를 추가해줍니다.

CustomAccessDeniedHandler

@Slf4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler {


    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.info("Access Denied Handler...............start... ");
        Gson gson = new Gson();

        String jsonStr = gson.toJson(Map.of("error", "ERROR_ACCESSDENIED"));

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpStatus.FORBIDDEN.value());

        PrintWriter printWriter = response.getWriter();
        printWriter.println(jsonStr);
        printWriter.close();
    }
}


참고

profile
배운 것을 기록합니다.

0개의 댓글