프로젝트/타로 : 봄

JWT Token 인증 인가 ( with. spring security)

아. 이렇게 하면 될거 같은데.. 2024. 8. 20. 21:49
728x90




JWT?

JWT(Token) 또는 JSON Web Token은 JSON 객체를 사용하여 두 개체 간에 정보를 안전하게 전달하기 위한 컴팩트하고 독립적인 방식이다. JWT는 주로 인증 및 정보 교환에 사용되며, 보통 세 가지 부분으로 구성된다

 

헤더(Header)

JWT의 유형과 해싱 알고리즘을 지정한다

 

페이로드

토큰에 담을 실제 정보를 포함한다

 

서명

JWT의 무결성을 보장하기 위해 사용된다. 서명은 헤더, 페이로드, 그리고 비밀키를 결합 하여 생성되며, 이를 통해 토큰이 변조되지 않았음을 확인한다.

 

JWT예시


Spring Security?

Spring Security

스프링 프레임워크에서 제공하는 보안 프레임 워크로, 웹 애플리케이션과 서비스에 대한 인증 및 인가를 구현하고 관리하는 데 사용된다. Spring Security는 매우 강력하고 유연한 보안 솔루션을 제공하여 애플리케이션 에 적용할 수 있다.

주요 개념

  • 인증 (Authentication): 사용자가 누구인지 확인하는 과정입니다. 보통 사용자 이름과 비밀번호 같은 자격 증명을 통해 이루어지며, Spring Security는 여러 가지 인증 방법(예: 폼 로그인, OAuth2, LDAP 등)을 지원한다.
  • 인가 (Authorization): 인증된 사용자가 애플리케이션의 특정 자원에 접근할 수 있는 권한이 있는지 확인하는 과정입니다. Spring Security는 URL 기반 접근 제어, 메서드 보안, 도메인 객체 보안 등 다양한 인가 전략을 제공한다.
  • 필터 체인 (Filter Chain): Spring Security는 일련의 보안 필터로 구성된 필터 체인을 사용해 HTTP 요청을 가로채고 처리합니다. 이러한 필터들은 인증, 인가, 세션 관리, CORS, CSRF 보호 등을 담당한다.
  • SecurityContext: 현재 인증된 사용자에 대한 정보를 저장하고 공유하는 컨텍스트입니다. 애플리케이션 전반에 걸쳐 사용자의 인증 상태를 추적하고 필요할 때 이를 참조할 수 있다.

 

HTTpSecurity 클래스 주요 메서드

메서드설명주요 설정 옵션 및 설명사용 예시

csrf() CSRF(Cross-Site Request Forgery) 보호 설정 - disable(): CSRF 보호 비활성화
- ignoringAntMatchers(): 특정 경로 무시
http.csrf().disable();
cors() CORS(Cross-Origin Resource Sharing) 설정 - configurationSource(): 특정 CORS 설정 적용
- disable(): CORS 보호 비활성화
http.cors().configurationSource(corsConfigurationSource());
authorizeHttpRequests() HTTP 요청에 대한 인가(Authorization) 규칙 설정 - requestMatchers(): 특정 경로에 대한 인가 설정
- permitAll(): 모든 접근 허용
- authenticated(): 인증된 사용자만 접근 가능
http.authorizeHttpRequests().requestMatchers("/admin/**").hasRole("ADMIN");
formLogin() 기본 로그인 폼 사용 시 설정 - loginPage(): 사용자 정의 로그인 페이지
- defaultSuccessUrl(): 로그인 성공 시 리다이렉트 URL
- failureUrl(): 로그인 실패 시 리다이렉트 URL
http.formLogin().loginPage("/login").defaultSuccessUrl("/home").permitAll();
httpBasic() HTTP 기본 인증(Basic Authentication) 설정 - disable(): HTTP 기본 인증 비활성화
- realmName(): 인증 리얼름 설정
http.httpBasic().disable();
sessionManagement() 세션 관리 설정 - sessionCreationPolicy(): 세션 생성 정책 설정
- maximumSessions(): 최대 세션 수 제한
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
exceptionHandling() 인증 및 인가 과정에서 발생하는 예외 처리 설정 - authenticationEntryPoint(): 인증되지 않은 사용자 접근 시 처리
- accessDeniedHandler(): 권한 없는 사용자 접근 시 처리
http.exceptionHandling().authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
addFilterBefore() / addFilterAfter() / addFilterAt() 특정 필터를 다른 필터 앞/뒤/위치에 추가 - addFilterBefore(): 지정된 필터 앞에 추가
- addFilterAfter(): 지정된 필터 뒤에 추가
- addFilterAt(): 지정된 필터 위치에 추가
http.addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);

 


AccessToken, RefreshToken

Access Token

  • 정의: Access Token은 클라이언트가 보호된 리소스에 접근할 수 있도록 해주는 짧은 수명의 자격 증명이다. 서버는 Access Token을 통해 클라이언트의 요청을 인증하고 해당 리소스에 대한 접근 권한을 확인한다.
  • 수명: 보안상의 이유로 Access Token은 일반적으로 짧은 수명을 가진다. 몇 분에서 몇 시간 정도로 설정되며, 만료되면 다시 사용할 수 없다.
  • 사용: Access Token은 API 요청 시 쿠키에 포함되어 서버에 전달된다. 서버는 이 토큰을 검증하여 요청을 처리할지 결정한다.

Refresh Token

  • 정의: Refresh Token은 Access Token이 만료된 후 새 Access Token을 발급받기 위해 사용하는 장기 수명의 자격 증명이다. 클라이언트는 Refresh Token을 서버에 제출하여 새로운 Access Token을 요청할 수 있다.
  • 수명: Refresh Token은 Access Token보다 훨씬 긴 수명을 가지며, 수명은 며칠, 몇 주, 심지어 몇 달까지 설정될 수 있다.
  • 사용: 클라이언트가 Access Token을 갱신할 필요가 있을 때마다 Refresh Token을 사용하여 새로운 Access Token을 발급받는다. 이 과정에서 사용자는 다시 인증할 필요가 없다.

본 타로:봄 서비스에서는 AccessToeken, RefreshToken 두가지 토큰을 이용하여 사용자정보를 인식하고 인가 처리 한다.

토큰들은 HTTP only Cookie에 저장되며 구현 방식에 따라 세션에 저장하는 경우도 있지만 본 서비스는 HTTP only Cookie 에 저장한다.

 


JWT 구현 방식

1. 회원 확인

클라이언트에서 로그인 요청이 들어오면 입력값인 이메일과 비밀번호를 받고, DB에서 정보를 조회해서 회원등록되어 있는 사용자 인지 구별한다

 

<MemberServiceImpl.java>

        String email = loginReqDto.getEmail();
        String password = loginReqDto.getPassword();
        log.info(email);
        Member member = memberRepository.findMemberByEmail(email).orElseThrow(
                () -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)
        );

        String name = member.getNickname();
        if(!passwordEncoder.matches(password, member.getPassword())){
            throw new BusinessException(ErrorCode.MEMBER_DIFF_PASSWORD);
        }

 

2. JWT 토큰 발급

이메일과 비밀번호가 DB의 정보와 일치하면 JWT 토큰을 발급해 준다.

 

<MemberServiceImpl.java>

        String accessToken = jwtUtil.createAccessToken(info);
        String refreshToken = jwtUtil.createRefreshToken(info);

 

<JwtUtil.java> - createToken 메서드

 private String createToken(CustomUserInfoDto member, long expireTime){

        Claims claims = Jwts.claims();

        log.info("[JwtUtil-createToken] email : {}", member.getEmail());

        claims.put("memberId", member.getMemberId());
        claims.put("email", member.getEmail());
        claims.put("memberType", member.getMemberTypeId());

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime tokenValidity = now.plusSeconds(expireTime);

        return Jwts.builder()
                        .setClaims(claims)
                        .setIssuedAt(Date.from(now.toInstant(ZoneOffset.of("+09:00")))) // 토큰 발행 시간
                        .setExpiration(Date.from(tokenValidity.toInstant(ZoneOffset.of("+09:00")))) // 토큰 만료 시간
                        .signWith(key, SignatureAlgorithm.HS256) // 암호화 알고리즘으로 토큰 암호화
                        .compact(); // 토큰을 문자열 형태로 반환
    }

 

이 메서드에서 토큰을 발행한다. 토큰 발행시간을 9시간을 추가하는 이유는 배포된 서버의 시간과 맞추기 위함이다.

토큰을 발급 받고 다시 MemberServiceImpl로 돌아와 쿠키에 토큰을 넣어 준다.

 

<MemberServiceImpl.java>

        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setSecure(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(60 * 60); // 1시간

        String storedRefreshToken = tokenService.getRefreshToken(member.getMemberId());

        // 만약 리프레시 토큰이 있는데 다시 로그인하면 기존 리프레시 토큰 삭제
        if(storedRefreshToken != null) {
            tokenService.deleteRefreshToken(member.getMemberId());
        }

        // 리프레시 토큰 쿠키 설정
        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setSecure(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(60 * 60 * 24 * 7); // 7일

        tokenService.saveRefreshToken(String.valueOf(member.getMemberId()), refreshToken, 7, TimeUnit.DAYS);

 

Response에 넣어줘서 쿠키를 설정하는 부분

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

 

3. 인증된 사용자 확인 (Spring security)

<WebSecurityConfig.java>

public class WebSecurityConfig {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;
    private final TokenService tokenService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomUserDetailsService customUserDetailsService) throws Exception {

        log.info("security");

        http.csrf((csrf) -> csrf.disable());        // CSRF 비활성화 -> JWT 쓰기 때문에
        http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
        // 세션 관리 상태 없음으로 구성, Spring Security가 세션 생성 해주거나 사용하지 않음
        http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS));

        // FormLogin, BasicHttp 비활성화
        http.formLogin((form) -> form.disable());
        http.httpBasic(AbstractHttpConfigurer::disable);

        // JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
        http.addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil, tokenService), UsernamePasswordAuthenticationFilter.class);

        http.exceptionHandling((exceptionHandling) -> exceptionHandling
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                .accessDeniedHandler(new AccessDeniedHandlerImpl())
        );

        // 권한 규칙 : login, signup 경로는 직접 service에서 인증처리 나머지는 여기서 인증
        // -> main경로도 추가해야하나?
        http.authorizeHttpRequests((authorizeRequests) -> {
            authorizeRequests.requestMatchers("/user/signup/**").permitAll() // 회원가입 api
                    .requestMatchers("/user/login/**").permitAll() // 로그인 api
                    .requestMatchers("/user/emailCheck/**").permitAll()
                    .requestMatchers("/user/emails/**").permitAll() // 이메일 중복 검사
                    //.requestMatchers("/**").permitAll()
                    .requestMatchers("/error/**").authenticated()
                    .anyRequest().authenticated(); // 위의 것 외에는 인증 없이 접근 불가
        });

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

//        config.setAllowedOrigins(Arrays.asList("https://localhost:3000","http://localhost:3000", "https://client:3000","http://client:3000", "https://i11c208.p.ssafy.io", "http://i11c208.p.ssafy.io"));
        config.setAllowedOriginPatterns(Collections.singletonList("*"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT"));
        // todo : 여기에 허용할 헤더 목록
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);

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

        return source;
    }

}

 

이 SpringSecurity 설정에서 JWTAuthFilter를 직접 만들어 JWT 유효성을 검증하고 다음 필터체인으로 던질지 말지 결정한다.

http.addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil, tokenService), UsernamePasswordAuthenticationFilter.class);

 

이 부분이 필터 앞에 JWT 인증 절차를 넣는 부분이다.

 

<JwtAuthFilter.java>

String accessToken = getJwtFromCookies(request, "accessToken", response);
        String refreshToken = getJwtFromCookies(request, "refreshToken", response);

        // 액세스 토큰 검증
        if (accessToken != null && jwtUtil.validateToken(accessToken)) {
            Long memberId = jwtUtil.getMemberId(accessToken);

            // 유저와 토큰 일치 시 CustomUserDetails 생성
            CustomUserDetails userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(String.valueOf(memberId));

            if (userDetails != null) {
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } else if (refreshToken != null && jwtUtil.validateToken(refreshToken)) {
            Long memberIdFromRefreshToken = jwtUtil.getMemberId(refreshToken);

            // 레디스에서 저장된 리프레시 토큰과 비교
            String storedRefreshToken = tokenService.getRefreshToken(memberIdFromRefreshToken);
            if (refreshToken.equals(storedRefreshToken)) {
                // 액세스 토큰 재발급
                CustomUserDetails userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(String.valueOf(memberIdFromRefreshToken));
                String newAccessToken = jwtUtil.createAccessToken(CustomUserInfoDto.builder()
                        .memberId(userDetails.getMemberId())
                        .email(userDetails.getEmail())
                        .memberTypeId(userDetails.getMemberTypeId())
                        .build());

                Cookie newAccessTokenCookie = new Cookie("accessToken", newAccessToken);
                newAccessTokenCookie.setHttpOnly(true);
                newAccessTokenCookie.setSecure(true);
                newAccessTokenCookie.setPath("/");
                newAccessTokenCookie.setMaxAge(60 * 60); // 1시간

                response.addCookie(newAccessTokenCookie);
            }
        }

 

JWTAuthFilter에서 사용자 정보를 확인하는 부분 Redis DB에서 사용자 정보를 가져와 JWT 정보를 확인한다.

728x90
반응형