본문 바로가기

[개인프로젝트] 개발 공부

[프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2)

728x90
반응형

이전에 Spring Security 설정을 대략적으로 끝냈다면 이제 JWT를 설정해 보겠다.

 

우선 JWT dependencies를 작성해 주자

// build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.0.4'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.cac'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'

	// spring-data-jpa dependency
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	// maria-db
	implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '3.0.8'

	// spring-security dependency
	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    
    	// jwt token dependency 추가!
	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'
	implementation 'javax.xml.bind:jaxb-api:2.3.0' // java 버전에 따른 JAXV API 제거로 인한 의존성 추가
}

jar {
	zip64 = true
	enabled = false // plain 제거
}

tasks.named('test') {
	useJUnitPlatform()
}

 

728x90

 

다음으로 SecurityConfig에 적용해 줄 JwtSecurityConfig 파일과 Filter 파일을 만들어준다.

// JwtSecurityConfig.java
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void configure(HttpSecurity http) {

        // security 로직에 JwtFilter 등록
        http.addFilterBefore(
                new JwtFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class
        );
    }
}
// JwtFilter.java	 
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtTokenProvider jwtTokenProvider;

    // 실제 필터릴 로직
    // 토큰의 인증정보를 SecurityContext에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        String jwt = resolveToken(request);
        String requestURI = request.getRequestURI(); // 요청한 CONTROLLER URI 값을 찾아옴

        if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            System.out.println("Security Context : " + authentication.getName() + "\nURI : " + requestURI);
        } else {
            System.out.println("유효한 JWT 토큰이 없습니다, URI : " + requestURI);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오기 위한 메소드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

 

 

이제 클라이언트에서 요청이 오면 이곳 필터를 통해 유효한 토큰인지 확인하고 토큰 정보를 Context에 저장을 한다.

다음으로 유효한 토큰인지 확인하는 로직과 토큰을 발급해 주는 로직을 작성해 주자.

// JwtTokenProvider.java
@Component
public class JwtTokenProvider implements InitializingBean {
    // JWT 생성 및 검증을 위한 키
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private final long accessTokenValidityInMilliseconds;
    private final long refreshTokenValidityInMilliseconds;
    private final String secret;
    private Key key;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.accessTokenValidityInMilliseconds = tokenValidityInSeconds * 30; // 60,000ms : 1m(0.001d), 60000 * 30 = 30m
        this.refreshTokenValidityInMilliseconds = tokenValidityInSeconds * 60 * 24 * 2; // 60,000ms : 1m(0.001d), 60000 * 60 * 24 * 2 = 2d
    }

    // 빈이 생성되고 주입을 받은 후에 secret값을 Base64 Decode해서 key 변수에 할당하기 위해
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public JwtTokenResponseDto generateTokenDto(Authentication authentication, String loginType) {

        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        // 토큰의 expire 시간을 설정
        long now = (new Date()).getTime();
        Date accessExprTime = new Date(now + this.accessTokenValidityInMilliseconds);
        Date refreshExprTime = new Date(now + this.refreshTokenValidityInMilliseconds);

        String accessToken = "";
        String refreshToken = "";

        if(loginType.equals("SOCIAL")) {
            DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) authentication.getPrincipal();
            Object memberNo = defaultOAuth2User.getAttributes().get("memberNo");
            Object provider = defaultOAuth2User.getAttributes().get("provider");

            accessToken = Jwts.builder()
                    .setSubject(String.valueOf(memberNo))
                    .claim(AUTHORITIES_KEY, authorities)
                    .signWith(key, SignatureAlgorithm.HS512)
                    .setExpiration(accessExprTime)
                    .compact();

            refreshToken = Jwts.builder()
                    .setSubject(String.valueOf(memberNo))
                    .claim(AUTHORITIES_KEY, authorities)
                    .signWith(key, SignatureAlgorithm.HS512)
                    .setExpiration(refreshExprTime)
                    .compact();
        } else if(loginType.equals("COMMON")) {
            accessToken = Jwts.builder()
                    .setSubject(authentication.getName())       // payload "sub": memberNo
                    .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_*"
                    .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                    .setExpiration(accessExprTime)              // payload "exp": 1516239022 (예시)
                    .compact();

            refreshToken = Jwts.builder()
                    .setSubject(authentication.getName())
                    .claim(AUTHORITIES_KEY, authorities)
                    .signWith(key, SignatureAlgorithm.HS512)
                    .setExpiration(refreshExprTime)
                    .compact();
        }

        return JwtTokenResponseDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .accessTokenExpires(this.accessTokenValidityInMilliseconds - 5000) // 자동 로그아웃 기능을 위한 간극
                .accessTokenExpiresDate(accessExprTime)
                .build();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // 토큰의 유효성 검증을 수행
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            e.printStackTrace();
            System.out.println("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            e.printStackTrace();
            System.out.println("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            e.printStackTrace();
            System.out.println("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            e.printStackTrace();
            System.out.println("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

}

 

우선 application.properties에 secret키와 유효기간 변수를 임의로 넣어주고 @Value를 통해 가져와서 사용해 준다.

이것들은 이제 토큰을 생성할 때 필요한 재료들로 Signature로 사용할 key와 토큰의 유효기간을 정의할 것이다. 그리고 유효기간은 2가지로 나뉘어서 정의하는데 여기서 Access Token은 서버와 통신을 할 때 확인하는 요소로 사용이 되고 탈취에 대비하여 기간을 짧게 설정한다. Refresh Token은 Access Token 재발급을 위한 용도로 사용이 되고 비교적 기간을 길게 설정을 한다.

Access Token은 Request Header에 담아주고 Refresh Token은 쿠키로 저장을 하여 사용한다.

각 토큰의 Subject에는 사용자의 고유번호를 넣어주고, Claim에는 사용자 권한을 넣어준다. 넣어준 정보를 통해 해당 사용자와 관련된 정보를 불러와주거나 권한에 따른 기능들을 체크한다.

 

나 같은 경우에는 소셜 로그인을 했을 경우에도 관련된 정보를 토큰에 넣어주고 생성해야 했기에 분기처리를 해주었다. 일반 로그인과 소셜 로그인의 인증 정보가 다른 부분이 있기에 이렇게 해준 것이다.

 

getAuthentication 메서드 해당 토큰의 정보를 알고자 할 때 사용을 하고 validateToken 메서드는 토큰이 유효한지 체크를 해주는 것으로 이해하면 되겠다. 추가로 토큰과 관련된 DTO도 작성해 준다.

// JwtTokenResponseDto.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JwtTokenResponseDto {

    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpires;
    private Date accessTokenExpiresDate;
}
// JwtTokenRequestDto.java
@Data
@NoArgsConstructor
public class JwtTokenRequestDto {

    private String accessToken;
    private String refreshToken;
}

 

반응형

 

그리고 JWT와 관련된 ExceptionHandling을 작성해 준다.

// JwtAccessDeniedHandler.java
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
// JwtAuthenticationEntryPoint.java
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

 

 

이제 작성한 파일들을 SecurityConfig에 적용을 해주자

// SecurityConfig.java
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // Spring Security 활성화 및 웹 보안 설정 구성
public class SecurityConfig {

    private final CorsConfig corsConfig;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .cors()
                .configurationSource(corsConfig.corsConfigurationSource())

                .and()
                .formLogin().disable()
                .csrf().disable() // rest api 사용시 disable / token을 사용하는 방식일 경우 disable
                .exceptionHandling() // 추가!
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 추가!
                .accessDeniedHandler(jwtAccessDeniedHandler) // 추가!
                
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 

                .and()
                .authorizeHttpRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
                .requestMatchers("/").permitAll()
                .requestMatchers("/member/**").permitAll()
                .requestMatchers("/lecture/auth/**").permitAll()
                .requestMatchers("/faq/**").permitAll()
                .requestMatchers("/board/**").permitAll()
                .requestMatchers("/review/**").permitAll()
                .requestMatchers("/favicon.ico").permitAll()
                .anyRequest().authenticated()
                
                .and()
                .apply(new JwtSecurityConfig(jwtTokenProvider)); // 추가!

        return httpSecurity.build();
    }

}

 

여기까지 해준다면 JWT 설정까지 대략적으로 된 것이다.

이제 작성한 코드들을 사용하여 회원가입과 로그인 로직에 적용하여 사용을 해볼 것이다.

 

관련 포스팅

 

2024.03.01 - [[개인프로젝트] 개발 공부] - [프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)

 

[프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)

로그인, 회원가입 개발을 하다 보면 제일 많이 접근하고 자주 구현하게 되는 기능이 아닐까 싶다.예전 막 개발을 시작했을 때는 아무런 기능 없이 회원가입 때 아이디와 비밀번호를 DB에 넣어주

rlawo32.tistory.com

 

2024.03.08 - [[개인프로젝트] 개발 공부] - [프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3)

 

[프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3)

앞서 Spring Security와 JWT 관련 설정들을 모두 끝냈다면 이제 관련 로직들에 기능들을 적용하여 사용해 보겠다. 로그인 시 JWT 토큰을 발급해 줄 것이고 페이지 새로고침 및 로그인 연장 시 토큰 재

rlawo32.tistory.com

 

2024.03.18 - [[개인프로젝트] 개발 공부] - [프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)

 

[프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)

이전에 Spring Security + JWT를 이용해 로그인을 구현해 보았다. 이제 여기에 추가로 간편 로그인 기능도 구현해 보자. 간편 로그인은 사실상 어느 웹사이트를 가든 공통적으로 존재하는 요소이다.

rlawo32.tistory.com

 

2024.03.21 - [[개인프로젝트] 개발 공부] - [프로젝트] 9. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (2)

 

[프로젝트] 9. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (2)

이제 본격적으로 구현을 해보자 우선 각 플랫폼 별로 로그인 페이지를 호출하는 코드는 아래와 같다. // naver "http://localhost:8080/oauth2/authorization/naver"}>네이버 // kakao "http://localhost:8080/oauth2/authorizati

rlawo32.tistory.com

 

 

728x90
반응형