본문 바로가기

서버

Spring Boot3 - Security 간단 적용

728x90

1. 의존성 가져오기

2. Config 설정

3. Cors 재설정

4. PasswordEncoder 설정

5. 엔티티 셋팅

6. MemberDTO 구현

7. UserDetailsService 구현

8. JWT 생성

9. RefreshToken 생성

 


[1. 의존성 가져오기]

build.gradle 에서 

아래 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'

 


 

[2. Config설정]

 

CustomSecurityConfig.java 파일 생성 후 설정하기 

package com.zelkova.zelkova.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@RequiredArgsConstructor
@Log4j2
public class CustomSecurity {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  log.info("-------------security config ----------");
  return http.build();
 }
}

 


 

security 관련 로그 출력을 위해

"application.properties" 에 설정하기

loggin.level.org.springframework.security.web=trace

 

 


CORS설정, CSRF 공격 방어용 설정

package com.zelkova.zelkova.config;

import java.util.Arrays;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@RequiredArgsConstructor
@Log4j2
public class CustomSecurity {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  log.info("-------------security config ----------");

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

  // 요청이 들어올 때 마다 세션 생성을 막음
  http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

  // GET 이외 요청시 "CSRF 토큰"과 함께 요청이 와야함. 지금은 해제
  http.csrf(config -> config.disable()); 

  return http.build();
 }

 @Bean
 public CorsConfigurationSource configurationSource() {
  CorsConfiguration configuration = new CorsConfiguration();

  configuration.setAllowedOriginPatterns(Arrays.asList("*")); // CORS 설정할 경로
  configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE")); // CORS 가능한 Method
  configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type")); // CORS 가능한 Header
  configuration.setAllowCredentials(true); // 자격증명에 대한 위임 여부 설정

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

  return source;
 }
}

 

 

http.csrf(config -> config.disable());

위 코드는 

 

security가 컨테이너에 추가되면

"GET" 이외 요청시 "CSRF 토큰"이 있어야 증명확인이 되어 

end point 까지 갈 수 있다.

 

지금은 해제하여 사용하도록한다.

 


[4. PasswordEncoder 설정]

package com.zelkova.zelkova.config;

import java.util.Arrays;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@RequiredArgsConstructor
@Log4j2
public class CustomSecurity { 
 
 ... 생략
 
 
 @Bean
 public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
 }
}

 


[5. 엔티티 셋팅]

 

Member.java

package com.zelkova.zelkova.domain;

import java.util.List;
import java.util.ArrayList;

import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "memberRoleList")
public class Member {
 @Id
 private String email;

 private String pw;

 private String nickname;

 private boolean social;

 // 값 타입 객체. 테이블로 따로 관리. LazyLoading 형식(접근 필요없으면 쿼리x)
 @ElementCollection(fetch = FetchType.LAZY)
 @Builder.Default
 private List<MemberRole> memberRoleList = new ArrayList<>();

 public void addRole(MemberRole memberRole) {
  memberRoleList.add(memberRole);
 }

 public void clearRole() {
  memberRoleList.clear();
 }

 public void changeNickname(String nickname) {
  this.nickname = nickname;
 }

 public void changePw(String pw) {
  this.pw = pw;
 }

 public void changeSocial(boolean social) {
  this.social = social;
 }
}

 

 

MemberRole.java

package com.zelkova.zelkova.domain;

public enum MemberRole {
 USER, MANAGER, ADMIN;
}

 

 

MemberRepository.java

package com.zelkova.zelkova.repository;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.zelkova.zelkova.domain.Member;

public interface MemberRepository {
 @EntityGraph(attributePaths = { "memberRoleList" })
 @Query("select m from Member m where m.email = :email")
 Member getWithRoles(@Param("email") String email);
}

 

 


[6. MemberDTO 구현]

package com.zelkova.zelkova.dto;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class MemberDTO extends User {

 private String email;

 private String pw;

 private String nickname;

 private boolean social;

 private List<String> roleNames = new ArrayList<>();

 public MemberDTO(String email, String pw, String nickname, boolean social, List<String> roleNames) {
  super(email, pw,
    roleNames.stream().map(str -> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList()));

  this.email = email;
  this.pw = pw;
  this.nickname = nickname;
  this.social = social;
  this.roleNames = roleNames;
 }

 public Map<String, Object> getClaims() {
  Map<String, Object> dataMap = new HashMap<>();

  dataMap.put("email", email);
  dataMap.put("pw", pw);
  dataMap.put("nickname", nickname);
  dataMap.put("social", social);
  dataMap.put("roleNames", roleNames);

  return dataMap;
 }
}

 

Security에서 회원을 다루는 객체인 'User'를 상속했기 때문에

부모 클래스 프로퍼티를 삽입해주는 super 메소드를 이용한다.

 

'User'객체는 

username, password, authority 등이 프로퍼티로 존재한다.

 

authority는

GrantedAuthority 타입인데

 

해당 타입은 SimpleGrantedAuthority 클래스로 

처리가 가능하다. 

 

User 클래스 프로퍼티들

 

SimpleGrantedAuthority는

String을 인자로 받는 생성자가 있다.

role을 넣어주면 객체 생성할 수 있다.

SimpleGrantedAuthority

SimpleGrantedAuthority는

User 객체가 받는

GrantedAuthority 인터페이스를 구현한 클래스이다.


getClaims는

JWT생성할 때 필요한 내용이다.

 

나중에 알게됨

 


[7. UserDetailsService 구현]

우선 Security Context가 로그인 처리시 확인하는 인터페이스인

UserDetailsService 를 보면

 

loadUserByUsername의 메소드에 

username 문자열만 넣으면 로그인 처리를 해준다. 

 

그런데 현재 로그인 처리를 위한

Security Context에서의 경로설정을 하지 않았기 때문에

 

먼저 경로설정을 해줘야한다.

 


[Security Context에서의 로그인 경로 설정]

public class CustomSecurity {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  log.info("-------------security config ----------");

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

  // 요청이 들어올 때 마다 세션 생성을 막음
  http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

  // GET 이외 요청시 "CSRF 토큰"과 함께 요청이 와야함. 지금은 해제
  http.csrf(config -> config.disable());

  http.formLogin(config -> config.loginPage("/api/member/login"));
  
  return http.build();
 }
 ...생략
 
 }

 

아래 코드만 추가하면 된다. 

http.formLogin(config -> config.loginPage("/api/member/login"))

 

HttpSecurity 클래스 내부에 formLogin 메소드를 살펴보려니

겹겹이 쌓여있어서 분석 실패했다.

 

시간 있으면 살펴보면 좋을 듯 싶다.

 

Security Context에서의 로그인 경로설정이 완료되었기 때문에

Security Context에서 로그인을 처리하는 부분을 

구현하여 커스텀 소스를 추가해보자

 


[UserDetailsService.java - Security Context에서의 로그인 처리기능 구현]

package com.zelkova.zelkova.security;

import java.util.stream.Collectors;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.zelkova.zelkova.domain.Member;
import com.zelkova.zelkova.dto.MemberDTO;
import com.zelkova.zelkova.repository.MemberRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Service
@RequiredArgsConstructor
@Log4j2
public class CustomUserDetailsService implements UserDetailsService {
 private final MemberRepository memberRepository;

 // Security Config에서 설정한 .formLogin에서의 경로에 Post로 던지면 이곳에 도착한다. (흐름은 Security
 // Context 구조특징임)
 @Override
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
  log.info("---------------loadUserByUserName");

  Member member = memberRepository.getWithRoles(username);

  if (member == null) {
   throw new UsernameNotFoundException("Not Found");
  }

  MemberDTO memberDTO = new MemberDTO(
    member.getEmail(),
    member.getPw(),
    member.getNickname(),
    member.isSocial(),
    member.getMemberRoleList().stream().map(memberRole -> memberRole.name()).collect(Collectors.toList()));

  log.info("memberDTO ::: " + memberDTO);
  return memberDTO;
 }

}

 

 

 

Security Context의 

로그인 처리기능을 구현했다.

 

이제는 성공시, 실패시 로직을 구현해야한다

로그인(UserDetailsService.java)

-> 성공시) AuthenticationSuccessHandler.java

-> 실패시) AuthenticationFailureHandler.java

 

아래 두가지를 구현해보자

AuthenticationSuccessHandler.java

AuthenticationFailureHandler.java


[ AuthenticationSuccessHandler.java - 로그인 성공시 처리]

 

그 전에 먼저 JSON 문자열 생성 라이브러리 추가하자

 implementation 'com.google.code.gson:gson:2.10.1'

 

 

[APILoginSuccessHandler.java]

package com.zelkova.zelkova.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.google.gson.Gson;
import com.zelkova.zelkova.dto.MemberDTO;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {
 @Override
 public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
   Authentication authentication) throws IOException, ServletException {

  log.info("---------------------------success handler start");
  log.info(authentication);
  log.info("---------------------------success handler finish");

  MemberDTO memberDTO = (MemberDTO) authentication.getPrincipal();

  Map<String, Object> claims = memberDTO.getClaims();

  claims.put("accessToken", "");
  claims.put("refreshToken", "");

  Gson gson = new Gson();

  String jsonStr = gson.toJson(claims);

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

}

 

 

package com.zelkova.zelkova.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import com.google.gson.Gson;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginFailureHandler implements AuthenticationFailureHandler {
 @Override
 public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
   AuthenticationException exception) throws IOException, ServletException {

  Gson gson = new Gson();

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

  response.setContentType("application/json");
  PrintWriter printWriter = response.getWriter();
  printWriter.println(jsonStr);
  printWriter.close();
 }

}

 

 

Security Context의 설정값이 있는 SecurityConfig에서 

로그인 후 해당 클래스로 이동시키기 위해

흐름을 잡아주는 설정을 해줘야한다.

 

  http.formLogin(config -> {
   config.loginPage("/api/member/login");
   config.successHandler(new APILoginSuccessHandler());
   config.failureHandler(new APILoginFailureHandler());
  });

 


[ 8. JWT 생성]

 

JWT = 헤더 + 페이로드 + 서명

 

accessToken(10분), refreshToken(1시간) 두개를 발급

accessToken이 만료되면

갈아 끼우고

기존의 refreshToken은 1시간짜리로 재발급한다.

 


 

JWT 라이브러리 추가

https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api

 

build.gradle에 추가

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

 


JWT 처리 기능 추가

 

예외처리를 위한 클래스

package com.zelkova.zelkova.util;

public class CustomJWTException extends RuntimeException {
 public CustomJWTException(String msg) {
  super(msg);
 }

}

 

 

JWT생성 및 검증 클래스

package com.zelkova.zelkova.util;

import java.io.UnsupportedEncodingException;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.Map;

import javax.crypto.SecretKey;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.InvalidClaimException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.WeakKeyException;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTUtil {

 private static String key = "1234567890123456789012345678901234567890";

 public static String generateToken(Map<String, Object> valueMap, int min) {
  SecretKey key = null;

  try {
   key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
  } catch (WeakKeyException | UnsupportedEncodingException e) {
   e.printStackTrace();
  }

  String jwtStr = Jwts.builder()
    .setHeader(Map.of("typ", "JWT"))
    .setClaims(valueMap)
    .setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
    .setExpiration(Date.from(ZonedDateTime.now().plusMinutes(min).toInstant()))
    .signWith(key)
    .compact();

  return jwtStr;
 }

 public static Map<String, Object> validateToken(String token) {
  Map<String, Object> claim = null;

  SecretKey key;
  try {
   key = Keys.hmacShaKeyFor(JWTUtil.key.getBytes("UTF-8"));
   claim = Jwts.parserBuilder()
     .setSigningKey(key)
     .build()
     .parseClaimsJws(token)
     .getBody();
  } catch (MalformedJwtException malformedJwtException) {
   throw new CustomJWTException("malformed");
  } catch (ExpiredJwtException expiredJwtException) {
   throw new CustomJWTException("expired");
  } catch (InvalidClaimException invalidClaimException) {
   throw new CustomJWTException("invalidClaimException");
  } catch (JwtException jwtException) {
   throw new CustomJWTException("jwtException");
  } catch (Exception e) {
   throw new CustomJWTException("Error");
  }

  return claim;
 }
}

 

 

APILoginSuccessHandler.java에서

token 추가해주자

 

package com.zelkova.zelkova.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.google.gson.Gson;
import com.zelkova.zelkova.dto.MemberDTO;
import com.zelkova.zelkova.util.JWTUtil;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException, ServletException {

    log.info("---------------------------success handler start");
    log.info(authentication);
    log.info("---------------------------success handler finish");

    MemberDTO memberDTO = (MemberDTO) authentication.getPrincipal();

    Map<String, Object> claims = memberDTO.getClaims();

	// 여기 두 줄 추가됨
    String accessToken = JWTUtil.generateToken(claims, 10);
    String refreshToken = JWTUtil.generateToken(claims, 60 * 24);

    claims.put("accessToken", accessToken);
    claims.put("refreshToken", refreshToken);

    Gson gson = new Gson();

    String jsonStr = gson.toJson(claims);

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

}

 

 


AccessToken 체크 포인트를 필터로 처리하기

 

SecurityConfig에서 필터 추가하기

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  log.info("-------------security config ----------");

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

  // 요청이 들어올 때 마다 세션 생성을 막음
  http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

  // GET 이외 요청시 "CSRF 토큰"과 함께 요청이 와야함. 지금은 해제
  http.csrf(config -> config.disable());

  http.formLogin(config -> {
   config.loginPage("/api/member/login");
   config.successHandler(new APILoginSuccessHandler());
   config.failureHandler(new APILoginFailureHandler());
  });

	// 여기추가됨
  http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class);

  return http.build();
 }

 

 

JWTCheckFilter.java

package com.zelkova.zelkova.security.filter;

import java.io.IOException;

import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

 @Override
 protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
  // 필터지정하지 않을 녀석들 선언
  String path = request.getRequestURI();

  log.info("check uri ............ " + path);

  return false;
 }

 @Override
 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
   throws ServletException, IOException {
  filterChain.doFilter(request, response);
 }

}

 

 


로그인, preflight, 로그인처리경로, 이미지 리소스 등은 

token 체크하는 필터 적용 예외처리를 두자

 

JWTCheckFilter.java

package com.zelkova.zelkova.security.filter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.web.filter.OncePerRequestFilter;

import com.google.gson.Gson;
import com.zelkova.zelkova.util.JWTUtil;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

 @Override
 protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
  // 필터지정하지 않을 녀석들 선언
  String path = request.getRequestURI();

  if (request.getMethod().equals("OPTIONS")) {
   return true;
  }

  log.info("check uri ............ " + path);

  if (path.startsWith("/api/member/")) {
   return true;
  }

  if (path.startsWith("/api/products/view/")) {
   return true;
  }

  return false;
 }

 @Override
 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
   throws ServletException, IOException {

  String authHeaderStr = request.getHeader("Authorization");

  try {
   String accessToken = authHeaderStr.substring(7);
   Map<String, Object> claims = JWTUtil.validateToken(accessToken);

   log.info("JWT claims " + claims);

   filterChain.doFilter(request, response);

  } catch (Exception e) {
   log.error("JWT ERROR");

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

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

 


[@PreAuthorize로 권한 여부에 따라 api 접근설정하기]

 

1. SecurityContext에게 나 "PreAuthorize" 쓸거야라고 말해주기

코드먼저 넣고 소스 분석하자

package com.zelkova.zelkova.config;

import java.util.Arrays;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import com.zelkova.zelkova.security.filter.JWTCheckFilter;
import com.zelkova.zelkova.security.handler.APILoginFailureHandler;
import com.zelkova.zelkova.security.handler.APILoginSuccessHandler;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@RequiredArgsConstructor
@Log4j2
@EnableMethodSecurity // @PreAuthorize를 위한 설정.(특정 메소드에 권한 확인 후 인가 부여)
public class CustomSecurity {

 @Bean
 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  log.info("-------------security config ----------");

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

  // 요청이 들어올 때 마다 세션 생성을 막음
  http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

  // GET 이외 요청시 "CSRF 토큰"과 함께 요청이 와야함. 지금은 해제
  http.csrf(config -> config.disable());

  // 로그인 경로설정
  // 성공,실패 핸들러 커스텀
  http.formLogin(config -> {
   config.loginPage("/api/member/login");
   config.successHandler(new APILoginSuccessHandler());
   config.failureHandler(new APILoginFailureHandler());
  });

  // JWT 체크할 경로 커스텀 설정
  http.addFilterBefore(new JWTCheckFilter(), UsernamePasswordAuthenticationFilter.class);

  return http.build();
 }

 @Bean
 public CorsConfigurationSource configurationSource() {
  CorsConfiguration configuration = new CorsConfiguration();

  configuration.setAllowedOriginPatterns(Arrays.asList("*"));
  configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
  configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
  configuration.setAllowCredentials(true);

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

  return source;
 }

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

 

 

@PreAuthorize를 사용하기 위해서는

SecurityContext에게 "나 PreAuthorize 사용할거야" 라고 알려줘야한다.

 

그 어노테이션은 

@EnableMethodsSecurity 이며

그 속을 보면 아래와 같다.

 

 

 

 

2. Controller에 테스트로 붙여보기

  @PreAuthorize("hasRole('ROLE_ADMIN')") // 권한설정
  @GetMapping("/list")
  public PageResponseDTO<BoardDTO> list(PageRequestDTO pageRequestDTO) {
    log.info("test");
    return boardSerivce.list(pageRequestDTO);
  }

 

3. SecurityConfig의 JWTFilter에서 인증 설정을 하기

코드먼저..

package com.zelkova.zelkova.security.filter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;
import java.util.List;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;

import com.google.gson.Gson;
import com.zelkova.zelkova.dto.MemberDTO;
import com.zelkova.zelkova.util.JWTUtil;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class JWTCheckFilter extends OncePerRequestFilter {

  @Override
  protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
    // 필터지정하지 않을 녀석들 선언
    String path = request.getRequestURI();

    if (request.getMethod().equals("OPTIONS")) {
      return true;
    }

    log.info("check uri ............ " + path);

    if (path.startsWith("/api/member/")) {
      return true;
    }

    // if (path.startsWith("/api/board/")) {
    // return true;
    // }

    return false;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

    String authHeaderStr = request.getHeader("Authorization");

    try {
      String accessToken = authHeaderStr.substring(7);
      Map<String, Object> claims = JWTUtil.validateToken(accessToken);

      log.info("JWT claims " + claims);

      String email = (String) claims.get("email");
      String pw = (String) claims.get("pw");
      String nickname = (String) claims.get("nickname");
      Boolean social = (Boolean) claims.get("social");
      List<String> roleNames = (List<String>) claims.get("roleNames");

      MemberDTO memberDTO = new MemberDTO(email, pw, nickname, social.booleanValue(), roleNames);

      log.info("------------------------");
      log.info(memberDTO);
      log.info(memberDTO.getAuthorities());

      UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberDTO,
          memberDTO.getAuthorities());

      SecurityContextHolder.getContext().setAuthentication(authenticationToken);
      ;

      filterChain.doFilter(request, response);

    } catch (Exception e) {
      log.error("JWT ERROR");

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

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

 

 

Security Context가 Framework에 들어오면

서버에 접근시 SecurityContext에서 먼저 로그인 인증을 확인한다.

 

그 중에서 

우리가 추가한 필터를 살펴보자.

 

서버 접근시 거의 최초로 만나는 필터다.

JWT증명에 대한 필터인데 그걸 커스텀으로 구현한

JWTCheckFilter를 보자

 

 

JWT 를 체크하는 부분이다. 

 

멤버정보의 인증권한을 체크하여 SecurityContext에 집어넣는 과정을 보자

 

가. 토큰에서 Member 정보를 추출하여 

나. 멤버정보와 인증권한들을 토큰화 시킨다.

다. 인증권한 토큰을 SecurityContext의 Authentication에 집어 넣는다.

 


포스트맨으로 테스트해보기

 

@hasRole 로 "ROLE_ADMIN" 만 설정했으니

"ROLE_USER"인 계정으로 테스트 해보자

 

1st. /api/member/login 으로 accessToken 받기

 

2nd. "ROLE_USER" 계정인 토큰 넣어서 /api/board/list 조회해보기

 

접근제한이 걸린 것을 확인할 수 있다. 

 


[9. Refresh 토큰]

package com.zelkova.zelkova.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.zelkova.zelkova.util.CustomJWTException;
import com.zelkova.zelkova.util.JWTUtil;

import lombok.extern.log4j.Log4j2;

@RestController
@Log4j2
public class APIRefreshController {

 @RequestMapping("/api/member/refresh")
 public Map<String, Object> refresh(@RequestHeader("Authorization") String authHeader, String refreshToken) {
  if (refreshToken == null) {
   throw new CustomJWTException("NULL_REFRESH");
  }

  if (authHeader == null || authHeader.length() < 7) {
   throw new CustomJWTException("INVALID_STRING");
  }

  String accessToken = authHeader.substring(7);

  // ACCESS TOKEN 살아있으면 그대로 리턴
  if (checkExpiredToken(accessToken) == false) {
   return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
  }

  // refresh check
  Map<String, Object> claims = JWTUtil.validateToken(refreshToken);

  String newAccessToken = JWTUtil.generateToken(claims, 10);

  // refreshToken 만료가 1시간도 안남았으면 새로발급
  String newRefreshToken = checkTime((Integer) claims.get("exp")) == true ? JWTUtil.generateToken(claims, 60 * 24)
    : refreshToken;

  return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
 }

 private boolean checkTime(Integer exp) {
  java.util.Date expDate = new java.util.Date((long) exp * (1000));
  long gap = expDate.getTime() - System.currentTimeMillis();

  long leftMin = gap / (1000 * 60);

  return leftMin < 60;
 }

 private boolean checkExpiredToken(String token) {
  try {
   JWTUtil.validateToken(token);

  } catch (CustomJWTException e) {
   if (e.getMessage().equals("Expired")) {
    return true;
   }
  }

  return false;
 }

}

 

 

만료시)

accessToken 재발급

-> refreshToken 만료유무 체크 후 조건에 따라 재발급