/*
*
* 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.
*/
화면없이 Ajax와 JSON을 이용해서 데이터를 주고받는 구조에서는 HttpSession이나 쿠키를 이용하는 기존의 인증방식에 제한받게됩니다. 이를 해결하기위해서 인증받은 사용자들은 특정한 문자열(토큰)을 이용하게되는데 이때 많이 사용하는 것이 JWT(JSON Web Token)입니다.
API서버
API서버는 쉽게말해서 '필요한 데이터만 제공하는 서버'를 의미합니다.
API서버는 화면을 제공하는것이 아니라 필요한 데이터를 호출하고 결과를 반환 받는 방식으로 동작합니다.
따라서 API서버에서 가장 먼저 눈에 띄는 특징은 화면을 제공하지 않는다는 점입니다.
브라우저에 필요한 화면의 코드(HTML)를 서버에서 만들어 전송하는 방식을 '서버사이드렌더링'(이하 SSR)이라고 하는데 JSP와 Tymeleaf가 이에 해당합니다. API서버는 화면구성은 별도의 클라이언트 프로그램에서 처리하고 서버에서는 순순한 데이터만을 전송하게 됩니다. 이러한 구성을 '클라이언트 사이드 렌더링'(이하 CSR)이라고 합니다.
CSR방식은 클라이언트에서 데이터를 가공해서 화면에 보여주기 때문에 데이터를 어떻게 구성해서 주고받은 것인지가 중요한데, 주로 JSON/XML포맷으로 데이터를 구성하고 호출하는 방식은 REST방식을 이용하는 경우가 많습니다.
API서버는 화면을 구성하지 않는다는 특징외에도 '무상태(stateless)'라는 특징도 있습니다.
이것은 사실 API서버의 특징이라기보다 REST나 HTTP의 특징이지만 개발에 약간의 차이만 있기때문에 주의해야합니다.
전통적인 SSR방식의 서비스는 쿠키와 세션을 이용해서 서버에서 사용자정보를 추적할수 있었습니다. 쿠키의 경우 쿠키를 발행한 서버를 호출할때만 전달되는 방식이고, 세션의 경우 서버 내부에서 JSESSIONID와 같은 이름의 쿠키를 통해서 사용자 정보를 보관하고 처리할수 있었습니다.
반면 API서버는 쿠키를 이용해서 데이터를 교환하는 방식이 아닙니다. API서버는 단순히 '요청(request)'과 '응답(response)'에서 발생한 부수적인 결과를 유지하지 않습니다. 예를들어 API서버는 JSESSIONID이름의 쿠키를 발행하거나 개발자가 직접 쿠키를 생성하지 않습니다. API서버는 순수하게 데이터를 요청하고 응답받는 방식으로 구성됩니다.
토크기반의 인증
API서버가 단순히 데이터만을 주고받을 때 외부에서 누구나 호출하는 URI를 알게되면 문제가 생기게 됩니다. 당연히 API서버에는 다양한 방법으로 특정한 사용자나 프로그램에서만 API서버를 호출할수 있도록 제한하는 방법이 필요합니다.
초창기 API서버는 주로 API를 호출하는 프로그램의 IP주소를 이용했습니다. API서버를 호출하는 쪽의 IP와 API서버내에 보관된 IP를 비교해서 허용된 IP에서만 API서버에서 결과를 만들어주는 방식입니다. IP와 더불어서 정해진 키값을 같이 사용하는것이 일반적인 형태입니다.
본인이 사용할 방식은 토큰을 이용하는 방식입니다. 토큰은 일종의 표식같은 역할을 하는 데이터입니다. 현실적으로 토큰은 서버와 클라이언트가 주고받는 '문자열'에 불과한데 API서버를 이용하고자하는 사람들은 API서버에서 토큰을 받아 보관하고 호출할때 자신이 가지고 있는 토큰을 같이 전달해서 API서버에서 이를 확인하는 방식입니다. 이런방식은 실생활에서 흔히 사용하는 입장권과 유사한 개념입니다.
Access Token / Refresh Token의 의미
입장권에 해당하는 토큰을 API서버에서는 'Access Token'이라고 합니다. Access Token은 말그대로 '특정한 자원에 접근할 권한이 잇는지를 검사'하기 위한 용도입니다. 입장권과 같기 때문에 외부에서 API를 호출할때 AccessToken을 함께전달하면 이를 이용해서 검증하고 그 결과에 따라서 요청을 처리합니다. 만일 악의적인 사용자에게 탈취당하면 문제가 발생합니다. 따라서 퇴대한 유효기간을 짧게 지정하고 사용자에게는 새로 발급받을수 있는 Refresh Token이라는것을 같이 생성해서 필요할때 다시 AccessToken을 발급받을수있도록 구성합니다.(RefreshToken이 필수는 아닙니다. 다시 AccessToken을 발급받기만 한다면 다른 방법이어도 상관없습니다.)
AccessToken, RefreshToken을 이용하는 정상적인 시나리오는 다음과 같습니다.
1. 사용자는 API서버로부터 AccessToken과 RefreshToken을 받습니다. 예를들어 AccessToken의 유효기간을 1일, RefreshToken의 유효기간은 10이라고 가정합니다.
2. 사용자가 특정한 작업을 하기위해서 AccessToken을 전달합니다.
3. 서버는 우선 AccessToken을 검사하고 해당 토큰이 유효한지 확인해서 작업을 처리합니다. 예를들어 AccessToken을 발행받은 당일이라면 문제없이 작업이 처리될수 있습니다.
토큰이 만료된경우라면,
1. 사용자가 AccessToken을 전달합니다. API서버에서는 AccessToken을 검증하는데 이과저에서 만료된토큰임을 확인하고 사용자에게 만료된 토큰임을 알려줍니다.
2. 사용자는 자신이 가지고 있는 RefreshToken을 전송해서 새로운 AccessToken을 요구합니다. API서버에서는 RefreshToken에 문제가 없다면 새로운 AccessToken을 생성해서 전달합니다. 이과정에서 RefreshToken이 거의 만료된 상황이라면 새로운 RefreshToken을 같이 전송할수도 있습니다.
토큰을 이용하는 방식은 네트워크로 데이터를 주고받기 때문에 항상 보안 문제가 있습니다.
AccessToken과 마찬가지로 RefreshToken 역시 탈취될때는 문제가 될수있습니다. 만일 둘다 탈취당하면 API서버에서는 사용자를 구분할수 없는 문제가 발생합니다. 그나마 현실적인 해결책은 RefreshToken으로 새로운 AccessToken이 생성되는것을 원래의 사용자가 알수있도록 하는것입니다.(에를들어 다른장소에서 로그인시 본인이 맞는지 확인하는 메일처럼)
RefreshToken은 유효시간을 조금 길게 주는데 AccessToken이 짧을수록 RefreshToken이 자주 사용하게 됩니다. 하지만 RefreshToken에도 유효시간이 있으므로 오랜시간동안 사용하지 않는다면 언젠가는 만료됩니다. 따라서 새로운 AccessToken을 생성할때 RefreshToken도 같이 생성해서 전달하는 방식이 많이 사용됩니다.
인증을 위한 프로젝트 구성
1. 스프링시큐리티의필터를이용해서토크들을 검사 - 서블릿의 필터(Filter)와 유사하지만스프링의 빈들을사용할수있다는 장점이 있습니다.
2. 화면구성이필요하지않으므로Thymeleaf를 사용하지않음
3. 자동으로 세션/쿠키를 생성하지않음(무상태로처리)
4. JWT문자열을 생성해서 토큰으로 사용
예제프로젝트 생성
1. AccessToken과 RefreshToken의 생성처리
2. AccessToken이 만료되었을때의 처리
3. RefreshToken의 검사와 만료가 얼마남지않은 RefreshToken의 갱신, 새로운 AccessToken의 생성
ModelMapper/SwaggerUI / Security설정
전 프로젝트의 config를 그대로 가져오겠습니다.(기존의 코드를 추가하기때문에 CustomSecurityConfig클래스는 에러가 발생합니다.)
CustomSecurityConfig클래스에서는 1)CSRF토큰의비활성화와 2)세션을 사용하지않는것을 지정합니다.
@Configuration
@Log4j2
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class CustomSecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
log.info("--------------- web config ---------------");
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
http.csrf().disable(); // CSRF토큰의 비활성화
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 세션사용X
return http.build();
}
}
CustomServletConfig에 별다른내용은없지만 나중에 파라미터타입의 변환을 추가할수있도록 클래스를 생성합니다.
이전과 달리 중간에 'files/'로 시작하는 경로는 스프링MVC에서 일반 파일경로로 처리하도록 지정해서 사용하도록 구성합니다.
@Configuration
@EnableWebMvc
public class CustomServletConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("classpath:/static/");
}
}
SwaggerConfig는 @RestController어노테이션이있는 컨트롤러에 대해서 API문서를 생성하도록 작성합니다.
@Configuration
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.OAS_30)
.useDefaultResponseMessages(false)
.select()
.apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
.paths(PathSelectors.any())
.build()
.apiInfo(apiInfo());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Boot API01 Project Swagger")
.build();
}
}
API사용자처리
API서버를 통해서 토큰을 얻을수있는 사용자들에 대한 처리를 진행하겠습니다.
API사용자는 별도의 domain패키지를 구성해서 엔티티클래스를 추가하고 Repository와 서비스계층을 구성합니다.
API사용자는 APIUser라는 이름으로 클래스를 생성합니다.
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class APIUser {
@Id
private String mid;
private String mpw;
public void changePw(String mpw) {
this.mpw = mpw;
}
}
APIUser는 일반 웹서비스와달리 AccessKey를 발급받을때 자신의 mid, mpw를 이용하므로 다른 정보들 없이 구성했습니다.
repository패키지를 생성하고 APIUserRepository인터페이스를 추가합니다.
public interface APIUserRepository extends JpaRepository<APIUser, String> {
}
테스트를 통해 정상 동작하는지 확인합니다.
@SpringBootTest
@Log4j2
public class APIUserReposirotyTests {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private APIUserRepository apiUserRepository;
@Test
public void testInserts() {
IntStream.range(1, 100).forEach(i -> {
APIUser apiUser = APIUser.builder()
.mid("apiuser" + i)
.mpw(passwordEncoder.encode("1111"))
.build();
apiUserRepository.save(apiUser);
});
}
}
스프링 시큐리티의 UserDetailsService와 DTO
사용자들의 인증자체는 스프링 시큐리티의 기능을 그대로 활용하도록 구성하겠습니다.
프로젝트에 security패키지를 구성하고 스프링시큐리티의 UserDetailsService인터페이스를 구현하는 APIUserDetailsService클래스를 추가합니다.
@Service
@Log4j2
@RequiredArgsConstructor
public class APIUserDetailsService implements UserDetailsService {
// 주입
private final APIUserRepository apiUserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
}
loadUserByUsername()의 결과를 처리하기위해서 dto패키지를 구성하고 APIUserDTO클래스를 추가합니다.
@Getter
@Setter
@ToString
public class APIUserDTO extends User {
private String mid;
private String mpw;
public APIUserDTO(String username, String password, Collection<GrantedAuthority> authorities) {
super(username, password, authorities);
this.mid = username;
this.mpw = password;
}
}
@Service
@Log4j2
@RequiredArgsConstructor
public class APIUserDetailsService implements UserDetailsService {
// 주입
private final APIUserRepository apiUserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<APIUser> result = apiUserRepository.findById(username);
APIUser apiUser = result.orElseThrow(() -> new UsernameNotFoundException("Cannot find mid"));
log.info("APIUserDetailsService apiUser--------------------------");
APIUserDTO dto = new APIUserDTO(
apiUser.getMid(),
apiUser.getMpw(),
List.of(new SimpleGrantedAuthority("ROLE_USER")));
log.info(dto);
return dto;
}
토큰인증을 위한 시큐리티 필터
스프링시큐리티는 수많은 필터로 구성되어있고, 이를 이용해서 컨트롤러에 도달하기 전에 필요한 인증처리를 진행할수있습니다.
1) 사용자가 자신의 아이디와 패스워드를 이용해서 AccessToken과 RefreshToken을 얻으려는 단계를 구현
2) 사용자가 AccessToken을 이용해서 컨트롤러를 호출하고자할때 인증과 권한을 체크하는 기능을 구현
인증과 JWT발행처리
사용자의 아이디와 패스워드를 이용해서 JWT문자열을 발행하는 기능은 컨트롤러를 이용할수도 있지만 스프링시큐리티의 AbstractAuthenticationProcessingFilter클래스를 이용하면 좀 더 완전한 분리가 가능합니다.
APILoginFilter라는 필터를 이용해서 인증단계를 처리하고 인증에 성공했을때는 AccessToken과 RefreshToken을 전송하도록 구현하겠습니다.
security패키지에 filter패키지를 구성하고 AbstractAuthenticationProcessingFilter클래스를 상속받는 APILoginFilter를 추가합니다.
@Log4j2
public class APILoginFilter extends AbstractAuthenticationProcessingFilter {
public APILoginFilter(String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
log.info("APILoginFilter----------------------");
return null;
}
}
AbstractAuthenticationProcessingFilter는 로그인처리를 담당하기때문에 다른필터들과 달리 로그인을 처리하는 경로에 대한 설정과 실제인증처리를 담당하는 AuthenticationManager객체의 설정이 필수로 필요합니다. 이설정은 CustomSecurityConfig에서 처리합니다.
@Configuration
@Log4j2
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class CustomSecurityConfig {
// 주입
private final APIUserDetailsService apiUserDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
log.info("--------------- web config ---------------");
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
// AuthenticationManger설정
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.userDetailsService(apiUserDetailsService)
.passwordEncoder(passwordEncoder());
// Get AuthenticationManager
AuthenticationManager authenticationManager =
authenticationManagerBuilder.build();
// 반드시 필요
http.authenticationManager(authenticationManager);
// APILoginFilter
APILoginFilter apiLoginFilter = new APILoginFilter("/generateToken");
apiLoginFilter.setAuthenticationManager(authenticationManager);
// APILoginFilter의 위치조정
http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class);
// CSRF토큰의 비활성화
http.csrf().disable();
// 세션 사용 X
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
}
위의코드에서 APILoginFilter는 '/generateToken'이라는 경로로 지정되었고, 스프링시큐리티에서 username과 password를 처리하는 UsernamePasswordAuthenticationFilter의 앞쪽으로 동작하도록 설정되었습니다.
프로젝트를 실행하고 브라우저를 '/generateToken'경로를 호출하면 APILoginFilter가 실행되는것을 확인할수있습니다.
APILoginFilter의 JSON처리
APILoginFilter는 사용자의 아이디와 패스워드를 이용해서 JWT문자열을 생성하는 기능을 수행하기 위해서 사용자가 전달하는 mid, mpw값을 알아낼수있어야합니다. API서버는 POST방식으로 JSON문자열을 이용하는것이 일반적이므로 이를 APILoginFilter에 반영하도록 합니다. build.gradle에 Gson라이브러리를 추가합니다.
implementation 'com.google.code.gson:2.8.9'
APILoginFilter는 POST방식으로 요청이 들어올때 JSON문자열을 처리하는 parseRequestJSON()메소드를 구성하고 mid, mpw를 확인할수 있도록 합니다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
log.info("APILoginFilter----------------------");
if (request.getMethod().equalsIgnoreCase("GET")) {
log.info("GET METHOD NOT SUPPORT");
return null;
}
Map<String, String> jsonData = parseRequestJSON(request);
log.info(jsonData);
return null;
}
private Map<String, String> parseRequestJSON(HttpServletRequest request) {
//JSON 데이터를 분석해서 mid, mpw 전달값을 Map으로 처리
try(Reader reader = new InputStreamReader(request.getInputStream())) {
Gson gson = new Gson();
return gson.fromJson(reader, Map.class);
} catch (Exception e) {
log.error(e.getMessage());
}
return null;
}
변경된 APILoginFilter는 POST방식으로 동작해야 전송된 JSON데이터를 처리하므로 static폴더에 apiLogin.html파일을 다음과 같이 추가합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button class="btn1">generateToken</button>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
document.querySelector(".btn1").addEventListener("click", () => {
const data = {mid:"apiuser10", mpw:"1111"}
axios.post("http://localhost:8082/generateToken", data)
}, false)
</script>
</body>
</html>
apiLogin.html은 화면상의 [generateToken]버튼을 클릭하면 Axios를 이용해서 POST방식으로 '/generateToken'을 호출하고 이때 JSON문자열을 전송하게됩니다. 서버에는 전송된 JSON을 파싱해서 mid, mpw값을 알아낼수 있습니다.
Map으로 처리된 mid, mpw를 이용해서 로그인을 처리하는 부분은 UsernamePasswordAuthenticationToken인증정보를 만들어서 다음필터(UsernamePasswordAuthenticationFilter)에서 하도록 구성합니다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
log.info("APILoginFilter----------------------");
if (request.getMethod().equalsIgnoreCase("GET")) {
log.info("GET METHOD NOT SUPPORT");
return null;
}
Map<String, String> jsonData = parseRequestJSON(request);
log.info(jsonData);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
jsonData.get("mid"),
jsonData.get("mpw"));
return getAuthenticationManager().authenticate(authenticationToken);
}
정상적인 mid와 mpw값이 전달되면 SQL이 실행되고 APIUserDetailsService를 이용한 인증처리가 되는것을 확인할수있습니다.
인증처리되기는 했지만, 기존의 스프링시큐리티처럼 로그인 후 '/'와 같이 화면을 이동하는방식으로 동작하는것을 확인할수있습니다.
원하는 작업은 JWT문자열을 생성하는것이므로 이에대한 처리를 위해서 인증성공 후 처리작업을 담당하는 AuthenticationSuccessHandler를 이용해서 후처리를 합니다. sucurity패키지에 handler라는 패키지를 구성하고 APILoginSuccessHandler를 추가합니다.
@Log4j2
@RequiredArgsConstructor
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("Login Success Handler........");
}
}
APILoginSuccessHandler의 동작은 APILoginFilter와 연동되어야하므로 CustomSecurityConfig내부에서 이를 설정합니다.
기존에 APILoginFilter를 적용한 부분에 다음과 같이 APILoginSuccessHandler를 추가합니다.
// APILoginFilter
APILoginFilter apiLoginFilter = new APILoginFilter("/generateToken");
apiLoginFilter.setAuthenticationManager(authenticationManager);
// APILoginSuccessHandler
APILoginSuccessHandler successHandler = new APILoginSuccessHandler();
apiLoginFilter.setAuthenticationSuccessHandler(successHandler);
토큰생성과정에서 남은 작업은 APILoginSuccessHandler에서 AccessToken과 RefreshToken을 생성해서 전송하는 작업입니다.
이를위해서는 JWT문자열을 처리하는 방법을 학습해야합니다.
JWT문자열의 생성과 검증
JWT는 엄밀히 말해서 '인코딩된 문자열'입니다. JWT는 그게 '헤더, 페이로드, 서명'부분으로 작성되어 있는데 각 부분은 '.'을 이용해서 구분됩니다. 세부분중에 페이로드에는 클레임(claim)이라고 부르는 키/값으로 구성된 정보들을 저장합니다.
JWT문자열의 각 부분은 다음과 같이 구성됩니다.
부분 | 속성 | 설명 |
Header | typ | 토큰타입 |
alg | 해싱 알고리즘 | |
Payload | iss | 토큰 발급자 |
sub | 토큰 제목 | |
exp | 토큰 만료시간 | |
iat | 토큰 발급시간 | |
aud | 토큰 대상자 | |
nbf | 토큰 활성시간 | |
jti | JWT고유식별자 | |
signature | Header의 인코딩 + Payload의 인코딩값을 해싱 + 비밀키 |
개발자의 고민은 '어떻게 JWT를 생성하고 넘겨받은 JWT를 확인할수 있는가'입니다.
이부분은 JWT와 관련된 라이브러리를 이용해서 처리하는데 여러종류의 라이브러리가 존재합니다. 가장 흔히 사용하는 io.jsonwebtoken(이하 jjwt)라이브러리를 이용하도록 합니다. (가장 대중적인 0.9.1버전을 이용하겠습니다.)
implementation 'io.jsonwebtoken:jjwt:0.9.1'
JWT를 쉽게이용하기위해 프로젝트에 util패키지를 구성하고 JWTUtil클래스를 추가합니다.
@Component
@Log4j2
public class JWTUtil {
@Value("${org.rrumang.jwt.secret}")
private String key;
public String generateToken(Map<String, Object> valueMap, int days) {
log.info("generateKey..." + key);
return null;
}
public Map<String, Object> validateToken(String token) throws JwtException {
Map<String, Object> claim = null;
return claim;
}
}
JWTUtil에서 필요한 기능은 크게 JWT문자열을 생성하는 기능인 generateToken()과 토큰을 검증하는 validateToken()기능입니다.
JWTUtil에서 서명을 처리하기위해서 비밀키가 필요한데 이부분은 application.properties에 추가해서 사용합니다.
//JWT에서 사용할 비밀키
org.rrumang.jwt.secret=hello1234567890
JWTUtil 테스트환경 구성
JWT를 테스트하는 방법은 다음과 같은 단계를 통해서 확인합니다.
JWTUtil을 이용해서 JWT문자열 생성
생성된 문자열을 https://jwt.io 사이트를 통해서 정상적인지 검사
JWTUtil의 validateToken()을 통해서 jwt.io사이트의 검사결과와 일치하는지 확인
test폴더에는 util패키지를 추가하고 JWTUtilTests라는 클래스를 추가합니다.
@SpringBootTest
@Log4j2
public class JWTUtilTests {
@Autowired
private JWTUtil jwtUtil;
@Test
public void testGenerate() {
Map<String, Object> claimMap = Map.of("mid", "ABCDE");
String jwtStr = jwtUtil.generateToken(claimMap, 1);
log.info(jwtStr);
}
}
아직까지 JWT생성 자체는 안되지만 application.properties파일에 설정된 비밀키가 정상적으로 로딩되는지 확인할 수 있습니다.
JWT생성과 확인
JWTUtil에서 실제 JWT를 생성하기 위해서 generateToken()는 다음과 같이 수정합니다.
@Component
@Log4j2
public class JWTUtil {
@Value("${org.rrumang.jwt.secret}")
private String key;
public String generateToken(Map<String, Object> valueMap, int days) {
log.info("generateKey..." + key);
//헤더부분
Map<String, Object> headers = new HashMap<>();
headers.put("typ", "JWT");
headers.put("alg", "HS256");
//payload 부분설정
Map<String, Object> payloads = new HashMap<>();
payloads.putAll(valueMap);
//테스트시에는 짧은 유효기간
int time = (1) * days;
String jwtStr = Jwts.builder()
.setHeader(headers)
.setClaims(payloads)
.setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
.setExpiration(Date.from(ZonedDateTime.now().plusMinutes(time).toInstant()))
.signWith(SignatureAlgorithm.HS256, key.getBytes())
.compact();
return jwtStr;
}
public Map<String, Object> validateToken(String token) throws JwtException {
Map<String, Object> claim = null;
return claim;
}
}
특별히 주의깊게 봐야하는 부분은 JWT생성시 유효기간을 days라는 파라미터로 처리했지만 실제로는 plusMinutes()를 이용했으므로 분 단위로 처리되도록 작성된 부분입니다. 이는 짧은 유효기간이 테스트시에 유용하기 때문인데 개발이 완료되면 plusDays()로 변경해줄 필요가 있습니다.
Jwts.builder()를 이용해서 Header부분과 Payload부분등을 지정하고 발행시간과 서명을 이용해서 compact()를 수행하면 JWT문자열이 생성됩니다. 테스트코드를 실행하면 매번 새로운 문자열이 생성된것을 확인할 수 있습니다.
생성된 문자열은 기록을 위해 별도의 메모장을 이용해서 복사해 두도록 합니다.
생성된 JWT문자열이 정상적인지 확인하기 위해서 https://jwt.io사이트 기능을 이용합니다. 우선 비밀키값을 먼저 입력합니다.
비밀키가 변경되면 JWT문자열이 변경되므로 반드시 비밀키를 먼저 입력해두고 이후에 생성된 JWT문자열을 입력합니다.
JWT문자열 검증
JWTUtil을 이용해서 JWT문자열을 검증할때 가장 중요한 부분은 여러 종류의 예외가 발생하고 발생하는 예외를 JwtException이라는 상위 타입의 예외로 던지도록 구성하는 점입니다. 검증은 JWT문자열 자체의 구성이 잘못되었거나, JWT문자열의 유효시간이 지났거나, 서명에 문제가 있는 증의 여러 문제가 발행할 수 있습니다. 이 검증은 추가된 라이브러리의 Jwts.parser()를 이용해서 처리됩니다.
JWTUtil의 validateToken()은 다음과 같이 수정합니다.
public Map<String, Object> validateToken(String token) throws JwtException {
Map<String, Object> claim = null;
claim = Jwts.parser()
.setSigningKey(key.getBytes()) // Set Key
.parseClaimsJws(token) // 파싱 및 검증, 실패시 에러
.getBody();
return claim;
}
테스트코드는 이미 유효기간이 지난 JWT문자열을 이용해서 validateToken()을 실행해 봅니다.
@Test
public void testValidata() {
// 유효시간이 지난 토큰
String jwtStr = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2Nzc0NjgwOTgsIm1pZCI6IkFCQ0RFIiwiaWF0IjoxNjc3NDY4MDM4fQ.KcESgUr82TbPfw1sFDZfV5aJMrJJcqYVX8-yYOS7tAE";
Map<String, Object> claim = jwtUtil.validateToken(jwtStr);
log.info(claim);
}
토큰의 유효시간이 지났으므로 ExpiredJwtException예외가 발생합니다.
만일 고의로 문자열의 마지막 부분에 임의의 문자를 추가하면 서명에서 SignatureException예외가 발생하는것을 확인할 수 있습니다.
JWTUtil의 유효기간
정상적인지를 확인하기 위해 JWTUtil에서 유효기간을 일(day)단위로 변경합니다.
//테스트시에는 짧은 유효기간
int time = (60 * 24) * days;
테스트코드에는 JWT문자열을 생성해서 이를 검증하는 작업을 같이 수행하는 테스트 메소드를 작성해 봅니다.
@Test
public void testAll() {
String jwtStr = jwtUtil.generateToken(Map.of("mid", "AAAA", "email", "aaaa@bbb.com"), 1);
log.info(jwtStr);
Map<String, Object> claim = jwtUtil.validateToken(jwtStr);
log.info("MID : " + claim.get("mid"));
log.info("EMAIL : " + claim.get("email"));
}
mid와 email을 이용해서 JWT문자열을 생성하고 validateToken()을 실행합니다. 리턴값에는 mid와 email이 그대로 들어있는것을 확인할 수 있습니다.
Access Token 발행
JWT관련 문자열을 만들거나 검증할수 있다면 이제는 이를 언제 어떻게 활용해야 하는지를 다시 점검하고 구현해야 합니다.
사용자가 '/generateToken'을 POST방식으로 필요한 정보(mid, mpw)를 전달하면 APILoginFilter가 동작하고 인증처리가 된 후에는 APILoginSuccessHandler가 동작하게 됩니다.
APILoginSuccessHandler의 내부에서는 인증된 사용자에게 AccessToken/RefreshToken을 발행해 주기 위해서 JWTUtil을 이용합니다.
JWTUtil주입
APILoginSuccessHandler는 다음과 같이 JWTUtil을 주입받고 필요한 토큰들을 생성하도록 수정합니다.
@Log4j2
@RequiredArgsConstructor
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {
private final JWTUtil jwtUtil;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("Login Success Handler........");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
log.info(authentication);
log.info(authentication.getName()); // username
Map<String, Object> claim = Map.of("mid", authentication.getName());
// AccessToken 유효기간 1일
String accessToken = jwtUtil.generateToken(claim, 1);
// RefreshToken 유효기간 30일
String refreshToken = jwtUtil.generateToken(claim, 30);
Gson gson = new Gson();
Map<String, String> keyMap = Map.of("accessToken", accessToken, "refreshToken", refreshToken);
String jsonStr = gson.toJson(keyMap);
response.getWriter().println(jsonStr);
}
}
config패키지의 CustromSecurityConfig에 우선 JWTUtil을 주입하고 APILoginSuceessHandler에 이를 주입합니다.
public class CustomSecurityConfig {
// 주입
private final APIUserDetailsService apiUserDetailsService;
private final JWTUtil jwtUtil;
... 생략 ...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
... 생략 ...
// APILoginSuccessHandler
APILoginSuccessHandler successHandler = new APILoginSuccessHandler(jwtUtil);
// SuccessHandler 세팅
apiLoginFilter.setAuthenticationSuccessHandler(successHandler);
... 생략 ...
}
}
생성된 토큰의 확인
설정이 완료되었다면 '/files/apiLogin.html'을 이용해서 토큰들이 정상적으로 생성되었는지 확인합니다.
생성된 토큰은 https://jwt.io를 통해서 정상적인지 확인합니다.(반드시 비밀키를 먼저 입력합니다.)
생성된 AccessToken과 RefreshToken의 만료시간이 원하는대로 생성되었는지 확인합니다.
AccessToken 검증필터
AccessToken과 RefreshToken의 발행이 가능해졌다면 특정한 경로를 호출할때 이 토큰들을 검사하고 문제가 없을때만 접근가능하도록 구성해볼 필요가 있습니다. 이 작업은 스프링시큐리티에서 필터를 추가해 처리하도록 구성합니다.
스프링시큐리티가 웹환경에서 동작할때는 여러 종류의 필터를 통해서 동작합니다. 필터를 구성하는 일은 기존의 서블릿기반 필터를 이용할수도 있지만 스프링시큐리티는 다른 번들을 연동해서 동작하는게 가능하다는 장점이 있습니다.
TokenChekFilter의 생성
프로젝트의 security/filter패키지에 TokenCheckFilter클래스를 추가합니다.
TokenCheckFilter는 현재 사용자가 로그인한 사용자인지 체크하는 로그인 체크용 필터와 유사하게 JWT토큰을 검사하는 역할을 합니다.
TokenCheckFilter는 org.springframework.web.filter.OncePerRequestFilter를 상속해서 구성하는데 OncePerRequestFilter는 하나의 요청에 대해서 한번씩 동작하는 필터로 서블릿API의 필터와 유사합니다.
구성하려는 TokenCheckFilter는 JWTUtil의 validateToken()기능을 활용해야 합니다.
@Log4j2
@RequiredArgsConstructor
public class TokenCheckFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.startsWith("/api")) {
filterChain.doFilter(request, response);
return;
}
log.info("Token check Filter...........");
log.info("JWTUtil : " + jwtUtil);
filterChain.doFilter(request, response);
}
}
TokenCheckFilter의 설정은 CustomSecurityConfig를 이용해서 지정합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
... 생략 ...
// APILoginFilter의 위치조정
http.addFilterBefore(tokenCheckFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
// CSRF토큰의 비활성화
http.csrf().disable();
// 세션 사용 X
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
private TokenCheckFilter tokenCheckFilter(JWTUtil jwtUtil) {
return new TokenCheckFilter(jwtUtil);
}
TokenCheckFilter 내 토큰 추출
TokenCheckFilter는 '/api/...'로 시작하는 모든 경로의 호출에 사용될것이고, 사용자는 해당 경로에 다음과 같은 상황으로 접근합니다.
1) AccessToken이 없는 경우 - 토큰이 없다는 메시지 전달필요
2) AccessToken이 잘못된경우 - 잘못된 토큰이라는 메시지 전달필요
3) AccessToken이 존재하지만 오래된값인 경우 - 토큰을 갱신하라는 메시지 전달필요
이처럼 다양한 상황을 처리하기위해 TokenCheckFilter는 JWTUtil에서 발생하는 예외에 따른 처리를 세밀하게 처리해야 합니다.
AccessToken의 추출과 검증
토큰검증 단계에서 가장 먼저 할일은 브라우저가 전송하는 AccessToken을 추출하는 것입니다. 일반적으로 AccessToken의 값은 HTTP Header중에 'Authorization'을 이용해서 전달됩니다. Authorization 헤더는 'type + 인증값'으로 작성되는데 type값들은 'Basic, Bearer, Digest, HOBA, Mutual'등을 이용합니다. 이중 OAuth나 JWT는 'Bearer'타입을 이용합니다.
TokenCheckFilter에서는 별도의 메소드를 이용해서 Authorization헤더를 추출하고 AccessToken을 검사하도록 합니다.
AccessToken에 문제가 있는 경우를 대비해서 security패키지에 별도로 exception패키지를 구성해서 AccessTokenException이라는 예외클래스를 미리 정의하도록 합니다.
AccessTokenException은 발생하는 예외의 종류를 미리 enum으로 구분해두고, 나중에 에러메시지를 전송할수있는 구조로 만듭니다.
public class AccessTokenException extends RuntimeException {
TOKEN_ERROR token_error;
public enum TOKEN_ERROR {
UNACCEPT(401, "Token is null or too short"),
BADTYPE(401, "Token type Bearer"),
MALFORM(403, "malFormed Token"),
BADSIGN(403, "BadSignatured Token"),
EXPIRED(403, "Expired Token");
private int status;
private String msg;
TOKEN_ERROR(int status, String msg) {
this.status = status;
this.msg = msg;
}
public int getStatus() {
return this.status;
}
public String getMsg() {
return this.msg;
}
}
public AccessTokenException(TOKEN_ERROR error) {
super(error.name());
this.token_error = error;
}
public void sendResponseError(HttpServletResponse response) {
response.setStatus(token_error.getStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Gson gson = new Gson();
String responseStr = gson.toJson(Map.of("msg", token_error.getMsg(), "time", new Date()));
try {
response.getWriter().println(responseStr);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
TokencheckFilter에는 AccessToken을 검증하는 validateAccessToken()메소드를 추가하고 예외종류에 따라서 AccessTokenException으로 처리합니다.
private Map<String, Object> validateAccessToken(HttpServletRequest request) throws AccessTokenException {
String headerStr = request.getHeader("Authorization");
if (headerStr == null || headerStr.length() < 8) {
throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.UNACCEPT);
}
// Bearer 생략
String tokenType = headerStr.substring(0, 6);
String tokenStr = headerStr.substring(7);
if (tokenType.equalsIgnoreCase("Bearer") == false) {
throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.BADTYPE);
}
try {
Map<String, Object> values = jwtUtil.validateToken(tokenStr);
return values;
}catch (MalformedJwtException malformedJwtException) {
log.error("MalformedJwtException----------------");
throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.MALFORM);
}catch (SignatureException signatureException) {
log.error("SignatureException------------");
throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.BADSIGN);
}catch (ExpiredJwtException expiredJwtException) {
log.error("ExpiredException--------------");
throw new AccessTokenException(AccessTokenException.TOKEN_ERROR.EXPIRED);
}
}
validateAccessToken()에는 JWTUtil의 validateToken()을 실행해서 문제가 생기면 발생하는 JwtException을 이용해서 예외내용을 출력하고 AccessTokenException을 던지도록 설계합니다.
TokenCheckFilter에 doFilterInternal()의 내용을 다음과 같이 수정해서 AccessToken에 문제가 있을때는 자동으로 브라우저에 에러 메시지를 상태코드와 함께 전송하도록 처리합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.startsWith("/api")) {
filterChain.doFilter(request, response);
return;
}
log.info("Token check Filter...........");
log.info("JWTUtil : " + jwtUtil);
try {
validateAccessToken(request);
filterChain.doFilter(request, response);
} catch (AccessTokenException accessTokenException) {
accessTokenException.sendResponseError(response);
}
}
Swagger UI에서 헤더처리
Swagger UI는 'Authorization'과 같이 보안과 관련된 헤더를 추가하기 위해서 config패키지에 SwaggerConfig를 수정해야 합니다.
(import할때 스프링이아닌 Swagger관련 API를 이용해야하므로 주의가 필요합니다.)
@Configuration
public class SwaggerConfig {
@Bean
public Docket api() {
return new Docket(DocumentationType.OAS_30)
.useDefaultResponseMessages(false)
.select()
.apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
.paths(PathSelectors.any())
.build()
.securitySchemes(List.of(apiKey())) // 추가된 부분
.securityContexts(List.of(securityContext())) // 추가된 부분
.apiInfo(apiInfo());
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Boot API01 Project Swagger")
.build();
}
private ApiKey apiKey() {
return new ApiKey("Authorization", "Bearer Token", "header");
}
private SecurityContext securityContext() {
return SecurityContext.builder().securityReferences(defaultAuth())
.operationSelector(selector -> selector.requestMappingPattern().startsWith("/api")).build();
}
private List<SecurityReference> defaultAuth() {
AuthorizationScope authorizationScope = new AuthorizationScope("global", "global access");
return List.of(new SecurityReference("Authorization", new AuthorizationScope[] {authorizationScope}));
}
}
변경된 SwaggerConfig에서는 '/api/'로 시작하는 경로들에 대해서 Authorization헤더를 지정하도록 설정합니다.
위의 설정이 반영되면 '/swagger-ui/index.html'에서는 상단에 [Authorize]버튼이 생성되고 Authorization헤더의 값을 입력할 수 있는 모달창이 보이게 됩니다.
RefreshToken 처리
만료된 토크이 전송되는 경우에 사용자는 다시 서버네 AccessToken을 갱신해 달라고 요구해야 합니다.
'/refreshToken'이라는 경로를 이용해서 사용자가 다시 현재의 AccessToken과 RefreshToken을 전송해 주면 이를 처리하도록 합니다.
'/refreshToken'에는 주어진 토큰이 다음과 같은 검증과정으로 동작하도록 작성합니다.
1) AccessToken이 존재하는지 확인
2) RefreshToken의 만료여부 확인
3) RefreshToken의 만료기간이 지났다면 다시 인증을 통해서 토큰들을 발급받아야함을 전달
RefreshToken을 이용하는 과정에서는 다음과 같은 상황들이 발생할수 있습니다.
1) RefreshToken의 만료기간이 충분히 남았으므로 AccessToken만 새로 만들어지는 경우
2) RefreshToken자체도 만료기간이 얼마 안남아서 AccessToken과 RefreshToken 모두 새로 만들어야 하는 경우
RefreshTokenFilter의 생성
RefreshTokenFilter는 security패키지내의 filter패키지에 추가합니다.
RefreshTokenFilter는 토큰갱신에 사용할 경로와 JWTUtil을 주입받도록 설계하고, 해당 경로가 아닌경우엔 다음의 필터들을 실행합니다.
@Log4j2
@RequiredArgsConstructor
public class RefreshTokenFilter extends OncePerRequestFilter {
private final String refreshPath;
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.equals(refreshPath)) {
log.info("skip refresh token filter...........");
filterChain.doFilter(request, response);
return;
}
log.info("Refresh Token Filter... run...........1");
}
}
RefreshTokenFilter설정
RefreshTokenFilter설정은 CustomSecurityFilter를 통해서 설정합니다. RefreshTokenFilter는 다른 JWT관련 필터들의 동작 이전에 할수있도록 TokenCheckFilter앞으로 배치합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
// AuthenticationManger설정
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.userDetailsService(apiUserDetailsService)
.passwordEncoder(passwordEncoder());
// Get AuthenticationManager
AuthenticationManager authenticationManager =
authenticationManagerBuilder.build();
// 반드시 필요
http.authenticationManager(authenticationManager);
// APILoginFilter
APILoginFilter apiLoginFilter = new APILoginFilter("/generateToken");
apiLoginFilter.setAuthenticationManager(authenticationManager);
// APILoginSuccessHandler
APILoginSuccessHandler successHandler = new APILoginSuccessHandler(jwtUtil);
// SuccessHandler 세팅
apiLoginFilter.setAuthenticationSuccessHandler(successHandler);
//APILoginFilter의 위치 조정
http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class);
// api로 시작하는 모든 경로는 tokenCheckFilter 동작
http.addFilterBefore(tokenCheckFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
// refreshToken 호출처리
http.addFilterBefore(new RefreshTokenFilter("/refreshToken", jwtUtil), TokenCheckFilter.class);
// CSRF토큰의 비활성화
http.csrf().disable();
// 세션 사용 X
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
프로젝트를 실행하고 '/refreshToken'경로를 호출해서 서버에서 동작하는지 확인합니다.
화면을 통한 RefreshTokenFilter 확인
RefreshTokenFilter를 호출하는 작업을 간단하게 하기 위해서 html파일들을 조금 수정해서 테스트가 가능하도록 합니다.
우선 apiLogin.html에서는 인증후에 전송되는 accessToken과 refreshToken값을 Local Storage를 이용해서 저장합니다.
<script>
document.querySelector(".btn1").addEventListener("click", () => {
const data = {mid:"apiuser10", mpw:"1111"}
axios.post("http://localhost:8082/generateToken", data).then(res => {
const accessToken = res.data.accessToken
const refreshToken = res.data.refreshToken
localStorage.setItem("accessToken", accessToken)
localStorage.setItem("refreshToken", refreshToken)
})
}, false)
</script>
'/refreshToken'호출하기
토큰들은 브라우저의 LocalStrorage에 보관되어있으므로 이를 이용해서 '/refreshToken'을 호출하는 화면을 제작합니다.
static폴더에 refreshTest.html파일을 추가합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Access TOKEN</h1>
<h3 class="accessOld"></h3>
<h3 class="accessResult"></h3>
<hr/>
<h1>REFRESH TOKEN</h1>
<h3 class="refreshOld"></h3>
<h3 class="refreshResult"></h3>
<button class="btn1">Refresh</button>
</body>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
const oldAccessToken = localStorage.getItem("accessToken")
const oldRefreshToken = localStorage.getItem("refreshToken")
document.querySelector(".accessOld").innerHTML = oldAccessToken
document.querySelector(".refreshOld").innerHTML = oldRefreshToken
document.querySelector(".btn1").addEventListener("click", () => {
axios.post('http://localhost:8082/refreshToken', {accessToken: oldAccessToken, refreshToken: oldRefreshToken})
.then(res => {
console.log(res.data)
const newAccessToken = res.data.accessToken
const newRefreshToken = res.data.refreshToken
document.querySelector(".accessResult").innerHTML = oldAccessToken!== newAccessToken?newAccessToken:'OLD'
document.querySelector(".refreshResult").innerHTML = oldRefreshToken !== newRefreshToken?newRefreshToken:"OLD"
})
.catch(error => {
console.error(error)
})
}, false)
</script>
</html>
화면에서 [Refresh]버튼을 누르면'/refreshToken'경로를 호출하는데 이때 기존의 토큰들은 JSON데이터로 전송하게 됩니다.
'/refreshToken'의 구현이 완료되면 화면에서 기존의 토큰들 아래에 새로운 토큰 혹은 기존의 토큰이 출력될것입니다.
RefreshToken구현과 예외처리
RefreshTokenFilter의 내부 구현은 다음과 같은 순서로 처리됩니다.
1) 전송된 JSON데이터에서 accessToken과 refreshToken을 추출
2) accessToken을 검사해서 토큰이 없거나 잘못된 토큰인 경우 에러 메시지 전송
3) refreshToken을 검사해서 토큰이 없거나 잘못된 토큰 혹은 만료된 토큰인 경우 에러 메시지 전송
4) 새로운 accessToken생성
5) 만료기한이 얼마 남지않은 경우 새로운 refreshToken생성
6) accessToken과 refreshToken 전송
RefreshTokenException
RefreshTokenFilter의 동작과정 중 여러종류의 예외사항이 발생하므로 이를 별도의 예외클래스로 분리해주도록 합니다.
security/exception패키지에 RefreshTokenException클래스를 추가합니다.
public class RefreshTokenException extends RuntimeException {
private ErrorCase errorCase;
public enum ErrorCase {
NO_ACCESS, BAD_ACCESS, NO_REFRESH, OLD_REFRESH, BAD_REFRESH
}
public RefreshTokenException(ErrorCase errorCase) {
super(errorCase.name());
this.errorCase = errorCase;
}
public void sendResponseError(HttpServletResponse response) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Gson gson = new Gson();
String responseStr = gson.toJson(Map.of("msg", errorCase.name(), "time", new Date()));
try {
response.getWriter().println(responseStr);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
토큰검사
RefreshTokenFilter의 doFilterInternal()내부에서는 우선 JSON데이터들을 처리해서 accessToken과 refreshToken을 확인합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.equals(refreshPath)) {
log.info("skip refresh token filter...........");
filterChain.doFilter(request, response);
return;
}
log.info("Refresh Token Filter... run...........1");
// 전송된 JSON에서 accessToken과 refreshToken을 얻어온다
Map<String, String> tokens = parseRequestJSON(request);
String accessToken = tokens.get("accessToken");
String refreshToken = tokens.get("refreshToken");
log.info("accessToken : " + accessToken);
log.info("refreshToken : " + refreshToken);
}
private Map<String, String> parseRequestJSON(HttpServletRequest request) {
// JSON데이터를 분석해서 Mid, mpw 전달값을 MAP으로 처리
try(Reader reader = new InputStreamReader(request.getInputStream())) {
Gson gson = new Gson();
return gson.fromJson(reader, Map.class);
} catch (Exception e) {
log.error(e.getMessage());
}
return null;
}
브라우저에서 '/files/refreshTest.html'을 이용해서 '/refreshToken'을 호출하면서 서버에서는 다음과 같이 토큰들을 추출하는것을 확인할수 있습니다.
accessToken의 검증은 checkAceessToken()이라는 별도의 메소드로 처리합니다.
문제가 생기면 RefreshTokenException을 전달합니다. AccessToken은 만료기간이 당연히 지났으므로 로그만 출력해주겠습니다.
private void checkAccessToken(String accessToken) throws RefreshTokenException {
try {
jwtUtil.validateToken(accessToken);
} catch (Exception exception) {
throw new RefreshTokenException(RefreshTokenException.ErrorCase.NO_ACCESS);
}
}
doFilterInternal()에서는 예외발생 시 메시지를 전송하고 메소드의 실행을 종료합니다.
log.info("accessToken : " + accessToken);
log.info("refreshToken : " + refreshToken);
try {
checkAccessToken(accessToken);
}catch (RefreshTokenException refreshTokenException) {
refreshTokenException.sendResponseError(response);
return; // 더이상 실행할 필요없음
}
RefreshToken의 경우도 검사가 필요합니다. RefreshToken이 존재하는지와 만료일이 지났는지를 확인하고, 새로운 토큰생성을 위해서 mid값을 얻어두도록 합니다.
RefreshTokenFilter내부에 checkRefreshToken()을 생성해서 문제가 생기면 RefreshTokenException을 발생하고, 정상이라면 토큰 내용물들을 Map으로 반환하도록 합니다.
private Map<String, Object> checkRefreshToken(String refreshToken) throws RefreshTokenException {
try {
Map<String, Object> values = jwtUtil.validateToken(refreshToken);
return values;
} catch (ExpiredJwtException expiredJwtException) {
throw new RefreshTokenException(RefreshTokenException.ErrorCase.OLD_REFRESH);
} catch (MalformedJwtException malformedJwtException) {
throw new RefreshTokenException(RefreshTokenException.ErrorCase.NO_REFRESH);
} catch (Exception exception) {
new RefreshTokenException(RefreshTokenException.ErrorCase.NO_REFRESH);
}
return null;
}
RefreshTokenFilter의 doFilterInternal()내붕에는 checkRefreshToken()을 처리하는 부분을 추가합니다.
private Map<String, Object> checkRefreshToken(String refreshToken) throws RefreshTokenException {
try {
Map<String, Object> values = jwtUtil.validateToken(refreshToken);
return values;
} catch (ExpiredJwtException expiredJwtException) {
throw new RefreshTokenException(RefreshTokenException.ErrorCase.OLD_REFRESH);
} catch (MalformedJwtException malformedJwtException) {
throw new RefreshTokenException(RefreshTokenException.ErrorCase.NO_REFRESH);
} catch (Exception exception) {
new RefreshTokenException(RefreshTokenException.ErrorCase.NO_REFRESH);
}
return null;
}
새로운 AccessToken발행
토큰들의 검증 단계가 끝났다면 이제 새로운 토큰들을 발행해 주어야 합니다.
1) AccessToken은 무조건 새로 발행합니다.
2) RefreshToken은 만료일이 얼마남지 않은 경우에 새로 발행합니다.
RefreshTokenFilter의 doFilterInternal()내부에는 RefreshToken의 내용물(claims)들을 이용해 다음 로직을 구현합니다.
try {
refreshClaims = checkRefreshToken(refreshToken);
log.info(refreshClaims);
//RefreshToken의 유효시간이 얼마 남지 않은 경우
Integer exp = (Integer) refreshClaims.get("exp");
Date expTime = new Date(Instant.ofEpochMilli(exp).toEpochMilli() * 1000);
Date current = new Date(System.currentTimeMillis());
// 만료시간과 현재시간의 간격계산
// 만일 3일 미만인 경우에는 RefreshToken도 다시 생성
long gapTime = (expTime.getTime() - current.getTime());
log.info("------------------------");
log.info("current : " + current);
log.info("expTime : " + expTime);
log.info("gap : " + gapTime);
String mid = (String) refreshClaims.get("mid");
// 이 상태까지오면 무조건 AcccessToken은 새로 생성
String accessTokenValue = jwtUtil.generateToken(Map.of("mid", mid), 1);
String refreshTokenValue = tokens.get("refreshToken");
// RefreshToken이 3일도 안남았을경우
if (gapTime < (1000 * 60 * 60 * 24 * 3)) {
log.info("new RefreshToken required...");
refreshTokenValue = jwtUtil.generateToken(Map.of("mid", mid), 30);
}
log.info("RefreshToken result.........");
log.info("accessToken : " + accessTokenValue);
log.info("refreshToken : " + refreshTokenValue);
} catch(RefreshTokenException refreshTokenException) {
refreshTokenException.sendResponseError(response);
return; // 더이상 실행할 코드가 없음
}
최종적으로 만들어진 토큰들을 전송하는 sendToken()를 작성하고 토큰들을 이용해서 메시지를 전송합니다.
private void sendTokens(String accessTokenValue, String refreshTokenValue, HttpServletResponse response) {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Gson gson = new Gson();
String jsonStr = gson.toJson(Map.of("accessToken", accessTokenValue, "refreshToken", refreshTokenValue));
try {
response.getWriter().println(jsonStr);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
doFilterInternal()의 새로운 토큰들을 생성한 후 sendTokens()를 호출합니다.
log.info("RefreshToken result.........");
log.info("accessToken : " + accessTokenValue);
log.info("refreshToken : " + refreshTokenValue);
sendTokens(accessTokenValue, refreshTokenValue, response);
'/refreshToken' 확인
브라우저로 '/refreshToken'의 동작을 확인해봅니다.
브라우저에서 '/apiLogin.html'로 새로운 토큰들을 받아 LocalStorage에 저장합니다.
'/files/refreshTest.html'을 이용해서 '/refreshToken'을 호출하게되면 기존의 AccessToken대신에 새로운 AccessToken이 만들어지는것을 확인할수 있고, RefreshToken은 유효기간에 따라 새로 생성되는것을 확인할수 있습니다.
RefreshToken의 만료일까지 시간이 충분히 남은 경우에 RefreshToken은 새로 발행되지 않습니다.
JWT의 한계
JWT를 이용해서 자원을 보호하는 방식은 태생적으로 문자열이라는 한계가 존재합니다.
예를들어 RefreshToken을 이용하는 부분만 생각해보아도 외부의 공격자가 RefreshToken을 탈취한 상황이라면 얼마든지 새로운 AccessToken을 생성할수 있기때문에 안전하지 않습니다.
이런상황을 조금이라도 보완하기 위해서 AccessToken과 RefreshToken을 데이터베이스에 보관하고 토큰을 갱신할때 데이터베이스의 값과 비교하는 방법을 이용할수 있습니다. 이경우 정상적인 사용자가 RefreshToken을 갱신하게 되면 공격자가 탈취한 RefreshToken이 쓸모없게 됩니다. 이런 방식과 유사하게 개로운 AccessToken이 발행될때 RefreshToken도 새로 발급하고 데이터베이스에 저장하는 방법도 있습니다. 이렇게되면 탈취한 RefreshToken의 유효시간이 같아지게되므로 원래의 취지와는 조금 다른 형태가 됩니다.
JWT를 안전하게 하기위한 대부분의 방법들이 근본적으로 공격자가 AccessToken과 RefreshToken을 탈취한 경우 최소한 1회이상은 작업이 가능하다는 점에서 모든 보완책이 완벽할수는 없습니다.
브라우저에서 JWT확인
API서버를 이용하는 구조에서는 브라우저에서 HTTP로 JWT토큰을 전송하고 필요한 자원에 접근하는 방식을 이용합니다.
AccessToken과 RefreshToken등의 활용을 우선적으로 같은 서버환경에서 먼저 체크한 후에 별도의 서버를 구축해서 확인합니다.
브라우저에서 JWT를 이용하는 시나리오를 정리하면 다음과 같습니다.
1) '/generateToken'을 호출해서 서버에서 발행한 AccessToken과 RefreshToken을 받는 단계입니다.
브라우저는 받은 토큰들을 저장해두고 필요할때마다 토크들을 찾아서 사용하도록 구성해야합니다.
2) 브라우저에서 임의의 페이지를 호출할때, 가지고있는 AccessToken을 같이 전달하고 정상적인 결과가 나오는지를 확인합니다.
3) AccessToken의 유효기간이 만료되는 상황에 대한 처리입니다.
AccessToken의 유효기간이 만료되면 서버에서는 에러메시지를 전송하는데 이를 판단해 브라우저는 RefreshToken으로 다시 새로운
AccessToken을 받고 원래 의도했던 작업을 수행해야합니다.(이과정을 'silentRefreshing'이라고 합니다.)
4) RefreshToken마저도 만료된 상황에 대한 처리힙니다. RefreshToken이 만료되면 새로운 AccessToken을 발생할수 없기때문에
사용자에세 1단계부터 다시 시작해야 함을 알려주어야 합니다.
1) - 토큰생성과 저장
1단계는 apiLogin.html에서 구현되었습니다. apiLogin.html에서 생성된 토큰들은 다음코드를 통해서 보관됩니다.
axios.post("http://localhost:8082/generateToken", data).then(res => {
console.log(res.data.accessToken)
const accessToken = res.data.accessToken
const refreshToken = res.data.refreshToken
localStorage.setItem("accessToken", accessToken)
localStorage.setItem("refreshToken", refreshToken)
})
2) - AccessToken을 이용한 접근
토큰들을 생성하고 보관했다면 다른 html에서 이를 이용해서 서버를 호출해 보겠습니다. static폴더에 sendJWT.html파일을 추가합니다.
sendJWT.html파일은 Axios라이브러리를 추가하고 버튼을 누르면 동작할수 있도록 구성합니다.
callServer()함수 내부에서는 LocalStorage에 보관된 AccessToken을 이용하도록 수정합니다.
만일 AccessToken이 없다면 경고창을 통해서 알수있게 예외를 발생하도록 구성합니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="result"></div>
<button class="btn1">CALL SERVER</button>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
const callServer = async() => {
console.log("call server 1...")
const accessToken = localStorage.getItem("accessToken")
if (!accessToken) throw 'Cannot Find AccessToken'
}
const resultDiv = document.querySelector(".result")
document.querySelector(".btn1").addEventListener("click", () => {
callServer().then(result => {
console.log(result)
}).catch(error => {
alert(error)
})
}, false)
</script>
</body>
</html>
만약 LocalStorage를 지운상태로 버튼을 누른다면 'Cannot Find AccessToken'경고창이 뜰것입니다.
반대로 AccessToken이 존재한다면 'api/sample/doA'를 호출하도록 코드를 수정해보겠습니다.
AccessToken은 HTTP의 'Authorization'헤더로 전송해야하므로 다음과 같이 작성합니다.
const callServer = async() => {
console.log("call server 1...")
const accessToken = localStorage.getItem("accessToken")
if (!accessToken) throw 'Cannot Find AccessToken'
const authHeader = {"Authorization": `Bearer ${accessToken}`}
const res = await axios.get("http://localhost:8082/api/sample/doA", {headers:authHeader})
return res.data
}
유효한 AccessToken이 전달되면 서버를 호출하고 sendJWT.html까지 전달됩니다.
3) - Refresh 처리
만료일이 지난 AccessToken을 전송하게 되면 현재 코드의 sendJWT.html에서 403예외가 발생하게 됩니다.
서버를 호출할때 'ExpiredToken'예외가 전달되면 별도의 처리가 필요하므로 예외관련된 코드를 추가합니다.
const callServer = async() => {
console.log("call server 1...")
const accessToken = localStorage.getItem("accessToken")
if (!accessToken) throw 'Cannot Find AccessToken'
const authHeader = {"Authorization": `Bearer ${accessToken}`}
try {
const res = await axios.get("http://localhost:8082/api/sample/doA", {headers:authHeader})
return res.data
} catch (err) {
if (err.response.data.msg === 'Expired Token') {
console.log("Refresh Your Token")
throw err.response.data.msg
}
}
}
AccessToken이 만료된 경우에는 'Expired Token'경고창이 뜹니다.
'Expired Token'메시지가 발생하는 경우에 반드시 한번은 '/refreshToken'을 호출하도록 수정합니다.
'/refreshToken'을 호출하는 함수를 추가하고 예외발생시 호출되도록 합니다.
const callServer = async() => {
console.log("call server 1...")
const accessToken = localStorage.getItem("accessToken")
if (!accessToken) throw 'Cannot Find AccessToken'
const authHeader = {"Authorization": `Bearer ${accessToken}`}
try {
const res = await axios.get("http://localhost:8082/api/sample/doA", {headers:authHeader})
return res.data
} catch (err) {
if (err.response.data.msg === 'Expired Token') {
console.log("Refresh Your Token")
try {
await callRefresh() // RefreshToken호출
console.log("new tokens...saved...") // 새로운 토큰 저장후 다시 원래 기능 호출
return callServer()
} catch (refreshErr) {
throw refreshErr.response.data.msg
}
}
}
}
const callRefresh = async () => {
const accessToken = localStorage.getItem("accessToken")
const refreshToken = localStorage.getItem("refreshToken")
const tokens = {accessToken, refreshToken}
const res = await axios.post("http://localhost:8082/refreshToken", tokens)
localStorage.setItem("accessToken", res.data.accessToken)
localStorage.setItem("refreshToken", res.data.refreshToken)
}
추가된 callRefresh()는 'Expired Token'메시지가 전송되면 기존의 토큰들을 전송해서 새로운 'AccessToken'을 받아 다시 localStorage에 저장합니다. 저장된 후 원래의 함수를 다시 호출합니다.
4) - 만료된 RefreshToken
만일 AccessToken과 RefreshToken이 모두 만료된 상황이라면 브라우저에서는 'OLD_REFRESH'에러 메시지가 전송됩니다.
이런경우에는 사용자가 다시 '/files/apiLogin.html'로 인증작업을 하고 새로운 토큰들을 발행받아야만 합니다.
Ajax와 CORS설정
API서버에서는 JSON데이터만 주고받는 방식이기때문에 실제로 화면이 존재하지 않습니다. 실제화면은 별도의 서버를 이용해서 처리하거나 리액트, Vue.js등을 이용하는 SPA(Single Page Application)방식으로 구현해서 물리적으로 분리되어 있는 서버나 프로그램에서 Ajax로 호출하게 됩니다.
이처럼 다른 서버에서 Ajax를 호출하면 '동일 출처 정책'을 위반하게되면서 Ajax호출은 정상적으로 이루어지지않습니다.
'동일 출처 정책'은 웹브라우저 보완을 위해 프로토콜, 호스트, 포트가 같은 서버로만 ajax요청을 주고 받을수있도록 한 정책으로 Ajax를 이용해서 다른 서버의 자원들을 마음대로 사용하는것을 막기 위한 보안조치입니다.
Ajax호출이 '동일 출처 정책'으로 인해서 제한받기때문에 이를 해결하려면 'CORS(Cross Origin Resource Sharing)'처리가 필요합니다. CORS처리를 하게되면 Ajax호출서버와 API서버가 다른 경우에도 접근과 처리를 허용할수있습니다.
Nginx 웹서버의 설치
Nginx서버를 세팅해서 html파일들을 서비스하고 Ajax를 이용해서 JWT를 사용해보도록 하겠습니다. Nginx설치하기
실제 HTML파일들의 내용은 html폴더에 위치하므로 작성했던 파일들을 html폴더에 넣고 편집할 필요가 있습니다.
html폴더의 경로는 usr/local/Cellar/nginx/1.23.3(버전)안에 있습니다. 터미널로 해당 경로를 찾아간 후,
open . 명령어로 finder로 해당 경로를 열어줍니다. VsCode를 이용해서 폴더를 열고 작업하겠습니다.
인텔리제이 예제 내에 static폴더의 내용을 복사해서 html폴더로 이동시킵니다.
Nginx서버를 실행하고 http://localhost/apiLogin.html을 실행해서 [generate Token]버튼을 누르면 문제가 발생합니다.
발생하는 문제는 Ajax호출에 사용하는 CORS문제와 GET방식이 아닌 POST방식을 이용할때 발생하는 Preflight문제입니다.
CORS문제해결
Ajax의 '동일 출처 정책'을 해결하는 방법에는 여러가지 방식이 있습니다.
브라우저에서 직접 서버를 호출하는대신 현재서버내 다른 프로그램을 이용해서 API서버를 호출하는 프록시(대리자)패턴이나,
JSONP같은 JSON이 아닌 순수 JS파일을 요청하는 방식이 있습니다.
가장 권장되는 방법은 당연히 서버에서 CORS관련 설정으로 해결하는것입니다. 서버에서 CORS설정은 주로 필터를 이용해서 브라우저의 응답메시지에 해당호출이 문제없다는 헤더정보들을 같이 전송하는 방식입니다. 스프링부트는 이러한 상황을 처리하기위해 웹관련 설정을 약간 조정하는 방식을 이용하거나 컨트롤러는 @CrossOrigin어노테이션을 이용해서 처리할수 있습니다. 스프링시큐리티필터들의 설정은 config폴더의 CustomSecurityConfig에 설정을 추가하는 방식으로 작성합니다.
CustomSecurityConfig 수정
프로젝트에서 config내 CustomSecurityConfig에 cors()관련 설정을 객체로 생성하고 이를 HttpSecurity객체에 반영합니다.
(코드를 작성할때 import는 org/springframework.web.cors로 시작하는 타입들을 사용합니다.)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("--------------- config ---------------");
... 생략 ...
http.cors(httpSecurityCorsConfigurer -> {
httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource());
});
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
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();
return source;
}
프로젝트를 재시작하고 Nginx서버의 'apiLogin.html'을 이용해서 '/generateToken'을 호출했을때 정상적으로 호출되는지 확인합니다.
마지막으로 정상적인 토큰들이 발행되었다면 'sendJWT.html'을 이용해서 정상으로 호출이 가능한지 확인합니다.
Todo API서비스
경로 | 메소드 | 파라미터 | 설명 |
/api/todo/ | POST | JSON | 신규 todo 입력 |
/api/todo/list | GET | size,page,from,to,keyword | PageResponseDTO를 JSON으로 만든결과 |
/api/todo/{tno} | GET | 특정 todo 조회 | |
/api/todo/{tno} | PUT | JSON | 특정 todo 수정 |
/api/todo/{tno} | DELETE | 특정 todo 삭제 |
Todo 엔티티/DTO, Repository
build.gradle의 Querydsl 설정
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
... 생략 ...
dependencies {
... 생략 ...
// Querydsl
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
annotationProcessor(
"javax.persistence:javax.persistence-api",
"javax.annotation:javax.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jpa"
)
}
... 생략 ...
sourceSets {
main {
java {
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}
domain > Todo
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@ToString
@Table(name = "tbl_todo_api")
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long tno;
private String title;
private LocalDate dueDate;
private String writer;
private boolean complete;
public void changeComplete(boolean complete) {
this.complete = complete;
}
public void changeDueDate(LocalDate dueDate) {
this.dueDate = dueDate;
}
public void changeTitle(String title) {
this.title = title;
}
}
작성된 Todo가 Querydsl이 사용하는 QTodo가 생성되는지 확인합니다.(gradle>other>compileJava실행후 build폴더확인)
repository > TodoRepository
public interface TodoRepository extends JpaRepository<Todo, Long> {
}
test > repository > TodoRepositoryTests
@SpringBootTest
@Log4j2
public class TodoRepositoryTests {
@Autowired
private TodoRepository todoRepository;
@Test
public void testInsert() {
IntStream.rangeClosed(1, 100).forEach(i -> {
Todo todo = Todo.builder()
.title("Todo..." + i)
.dueDate(LocalDate.of(2022, (i%12)+1, (i%30)+1))
.writer("user"+(i%10))
.build();
todoRepository.save(todo);
});
}
}
dto > TodoDTO
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TodoDTO {
private Long tno;
private String title;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul")
private LocalDate dueDate;
private String writer;
private boolean complete;
}
TodoService / TodoServiceImpl
service > TodoService, TodoServiceImpl
@Transactional
public interface TodoService {
Long register(TodoDTO todoDTO);
}
@Service
@RequiredArgsConstructor
@Log4j2
public class TodoServiceImpl implements TodoService {
private final TodoRepository todoRepository;
private final ModelMapper modelMapper;
@Override
public Long register(TodoDTO todoDTO) {
Todo todo = modelMapper.map(todoDTO, Todo.class);
long tno = todoRepository.save(todo).getTno();
return tno;
}
}
TodoController 처리
controller > TodoController
@RestController
@RequestMapping("/api/todo")
@Log4j2
@RequiredArgsConstructor
public class TodoController {
private final TodoService todoService;
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> register(@RequestBody TodoDTO todoDTO) {
log.info(todoDTO);
Long tno = todoService.register(todoDTO);
return Map.of("tno", tno);
}
}
프로젝트를 시작하고 Swagger-UI를 이용해서 확인합니다.
이과정에서 AccessToken이 필요하므로 미리 apiLogin.html을 이용해서 생성하고 이를 복사해서 사용합니다.
SwaggerUI를 이용해서 최종적인 테스트를 진행합니다.
조회와 목록처리
public interface TodoService {
... 생략 ...
TodoDTO read(long tno);
}
public class TodoServiceImpl implements TodoService {
... 생략 ...
@Override
public TodoDTO read(long tno) {
Optional<Todo> result = todoRepository.findById(tno);
Todo todo = result.orElseThrow();
return modelMapper.map(todo, TodoDTO.class);
}
}
public class TodoController {
... 생략 ...
@GetMapping("/{tno}")
public TodoDTO read(@PathVariable("tno") long tno) {
log.info("read tno : " + tno);
return todoService.read(tno);
}
}
페이징처리를 위한 준비
dto > PageRequestDTO, PageResponseDTO
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {
@Builder.Default
private int page = 1;
@Builder.Default
private int size = 10;
private String type; // 검색의 종류 t = 타이틀, c = 컨텐츠, w = 작성자, tc, tw, twc
private String keyword;
// 추가된 내용들
private LocalDate from;
private LocalDate to;
private Boolean completed;
public String[] getTypes() {
if (type == null || type.isEmpty()) {
return null;
}
return type.split("");
}
public Pageable getPageable(String...props) {
return PageRequest.of(this.page -1, this.size, Sort.by(props).descending());
}
private String link;
public String getLink() {
if (link == null) {
StringBuilder builder = new StringBuilder();
builder.append("page=" + this.page);
builder.append("&size=" + this.size);
if (type != null && type.length() > 0) {
builder.append("&type=" + type);
}
if (keyword != null) {
try {
builder.append("&keyword=" + URLEncoder.encode(keyword, "UTF-8"));
} catch (UnsupportedEncodingException e){
}
}
link = builder.toString();
}
return link;
}
}
@Getter
@ToString
public class PageResponseDTO<E> {
private int page;
private int size;
private int total;
// 시작 페이지 번호
private int start;
// 끝 페이지 번호
private int end;
// 이전 페이지의 존재여부
private boolean prev;
// 다음 페이지의 존재여부
private boolean next;
private List<E> dtoList;
@Builder(builderMethodName = "withAll")
public PageResponseDTO(PageRequestDTO pageRequestDTO, List<E> dtoList, int total) {
if (total <= 0) return;
this.page = pageRequestDTO.getPage();
this.size = pageRequestDTO.getSize();
this.total = total;
this.dtoList = dtoList;
this.end = (int)(Math.ceil(this.page/10.0)) * 10; // 화면에서의 마지막 번호
this.start = this.end -9; // 화면에서의 시작 번호
int last = (int)(Math.ceil((total/(double)size))); // 데이터의 개수를 계산한 마지막 페이지번호
this.end = end > last ? last: end;
this.prev = this.start > 1;
this.next = total > this.end * this.size;
}
}
Querydsl을 이용한 검색조건 처리
repository > search > TodoSearch, TodoSearchImpl
public interface TodoSearch {
Page<TodoDTO> list(PageRequestDTO pageRequestDTO);
}
public class TodoSearchImpl extends QuerydslRepositorySupport implements TodoSearch {
public TodoSearchImpl() {
super(Todo.class);
}
@Override
public Page<TodoDTO> list(PageRequestDTO pageRequestDTO) {
return null;
}
}
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoSearch {
}
public class TodoSearchImpl extends QuerydslRepositorySupport implements TodoSearch {
public TodoSearchImpl() {
super(Todo.class);
}
@Override
public Page<TodoDTO> list(PageRequestDTO pageRequestDTO) {
QTodo todo = QTodo.todo;
JPQLQuery<Todo> query = from(todo);
if (pageRequestDTO.getFrom() != null && pageRequestDTO.getTo() != null) {
BooleanBuilder fromToBuilder = new BooleanBuilder();
fromToBuilder.and(todo.dueDate.goe(pageRequestDTO.getFrom()));
fromToBuilder.and(todo.dueDate.loe(pageRequestDTO.getTo()));
query.where(fromToBuilder);
}
if (pageRequestDTO.getCompleted() != null) {
query.where(todo.complete.eq(pageRequestDTO.getCompleted()));
}
if (pageRequestDTO.getKeyword() != null) {
query.where(todo.title.contains(pageRequestDTO.getKeyword()));
}
this.getQuerydsl().applyPagination(pageRequestDTO.getPageable("tno"), query);
JPQLQuery<TodoDTO> dtoQuery = query.select(Projections.bean(TodoDTO.class,
todo.tno,
todo.title,
todo.dueDate,
todo.complete,
todo.writer
));
List<TodoDTO> list = dtoQuery.fetch();
long count = dtoQuery.fetchCount();
return new PageImpl<>(list, pageRequestDTO.getPageable("tno"), count);
}
}
test > TodoRepositoryTests
@Test
public void testSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.from(LocalDate.of(2022,10,01))
.to(LocalDate.of(2022, 12, 31))
.build();
Page<TodoDTO> result = todoRepository.list(pageRequestDTO);
result.forEach(todoDTO -> log.info(todoDTO));
}
서비스 계층 구현
public interface TodoService {
... 생략 ...
PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO);
}
public class TodoServiceImpl implements TodoService {
... 생략 ...
@Override
public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
Page<TodoDTO> result = todoRepository.list(pageRequestDTO);
return PageResponseDTO.<TodoDTO>withAll()
.pageRequestDTO(pageRequestDTO)
.dtoList(result.toList())
.total((int)result.getTotalElements())
.build();
}
}
public class TodoController {
... 생략 ...
@GetMapping(value = "/list", produces = MediaType.APPLICATION_JSON_VALUE)
public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
return todoService.list(pageRequestDTO);
}
}
Todo 수정과 삭제
public interface TodoService {
... 생략 ...
void remove(long tno);
void modify(TodoDTO todoDTO);
}
public class TodoServiceImpl implements TodoService {
... 생략 ...
@Override
public void remove(long tno) {
todoRepository.deleteById(tno);
}
@Override
public void modify(TodoDTO todoDTO) {
Optional<Todo> result = todoRepository.findById(todoDTO.getTno());
Todo todo = result.orElseThrow();
todo.changeTitle(todoDTO.getTitle());
todo.changeDueDate(todo.getDueDate());
todo.changeComplete(todoDTO.isComplete());
todoRepository.save(todo);
}
}
JWT와 @PreAuthorize
JWT기반의 인증작업은 일반적인 세션기반의 인증과 다르게 스프링시큐리티에서 사용하는 @PreAuthorize를 이용할수 없다는 단점이 있습니다. 게다가 API서버에서 CSRF토큰을 사용하지 않는경우가 많고, CustomSecurityConfig설정과 같이 세션을 생성하지않고,
기존에 만들어진 세션을 사용하지도 않는 SessionCreationPolicy.STATELESS설정하는 경우가 대부분입니다.
따라서 JWT인증을 이용할때 JWT안에 아이디(mid)를 이용해서 인증정보를 직접처리해서 스프링 시큐리티에서 활용할수 있도록 지정하는 방법을 생각할수 있습니다.
스프링시큐리티에서는 SecurityContextHolder라는 객체로 인증과 관련된 정보를 저장해서 컨트롤러 등에서 이를 활용할수있는데 이를 이용하면 @PreAuthorize를 이용할수있다는 장점이 있습니다.
TokenCheckFilter 수정
JWT토큰을 이용해서 인증정보를 처리해야하는 부분은 TokenCheckFilter이므로 JWT에 대한 검증이 끝난후에 인증정보를 구성해서 이를 활용하도록 구성합니다.
TokenCheckFilter는 APIUserDetailsService를 이용해서 JWT의 mid값으로 사용자정보를 얻어오도록 구성합니다.
@Log4j2
@RequiredArgsConstructor
public class TokenCheckFilter extends OncePerRequestFilter {
private final APIUserDetailsService apiUserDetailsService;
private final JWTUtil jwtUtil;
... 생략 ...
TokenCheckFilter를 설정하는 CustomSecurityConfig의 설정 역시 변경해줍니다.
config > CustomSecurityConfig
//APILoginFilter의 위치 조정
http.addFilterBefore(apiLoginFilter, UsernamePasswordAuthenticationFilter.class);
// api로 시작하는 모든 경로는 tokenCheckFilter 동작
http.addFilterBefore(tokenCheckFilter(jwtUtil, APIUserDetailsService), UsernamePasswordAuthenticationFilter.class);
tokenCheckFilter를 생성하는 부분도 수정합니다.
private TokenCheckFilter tokenCheckFilter(JWTUtil jwtUtil, APIUserDetailsService apiUserDetailsService) {
return new TokenCheckFilter(apiUserDetailsService, jwtUtil);
}
TokenCheckFilter내부에는 JWT의 mid값을 이용해서 UserDetails를 구하고 이를 활용해서 UsernamePasswordAuthenticationToken객체를 구성합니다.
log.info("Token check Filter...........");
log.info("JWTUtil : " + jwtUtil);
try {
Map<String, Object> payload = validateAccessToken(request);
// mid
String mid = (String) payload.get("mid");
log.info("mid : " + mid);
UserDetails userDetails = apiUserDetailsService.loadUserByUsername(mid);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (AccessTokenException accessTokenException) {
accessTokenException.sendResponseError(response);
}
UsernamePasswordAuthenticationToken 객체를 SecurityContextHolder.getContext().setAuthentication(authentication)
를 통해 스프링시큐리티에서 사용할수있도록하는 부분입니다.
@PreAuthorize 적용
변경된 TokenCheckFilter가 정상적으로 동작하는지 확인하기위해서 SampleController에 @PreAuthorize를 적용합니다.
@RestController
@RequestMapping("/api/sample")
public class SampleController {
@ApiOperation("Sample GET doA")
@GetMapping("/doA")
public List<String> doA() {
return Arrays.asList("AAA", "BBB", "CCC");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/doB")
public List<String> doB() {
return Arrays.asList("AdminAAA", "AdminBBB", "AdiminCCC");
}
}
JWT로 인증하는 사용자는 모두 "ROLE_USER'권한만을 가지고 있으므로 SampleController에 추가된 '/api/sample/doB'를 호출할때는 권한이 없으므로 호출이 불가능하게 됩니다.
AccessToken을 구하고 SwaggerUI를 이용해서 '/api/sample/doA'와 '/api/sample/doB'를 확인합니다.
doA의 경우 정상적으로 호출되지만 doB의 경우 403에러가 발생합니다.
JWT인증과 @PreAuthorize를 이용하는 경우 매번 호출할때마다 APIUserDetailsService를 이용해서 사용자정보를 다시 로딩해야하는 단점이 있습니다. 이과정에서 데이터베이스 호출 역시 피할수없습니다.
JWT를 이용하는 의미는 이미 적절한 토큰의 소유자가 인증 완료되었다고 가정해야 하므로 가능하다면 다시 인증정보를 구성하는것은 성능상 좋은 방식을 아니라고 할수있습니다.
'개발 > JAVA' 카테고리의 다른 글
사업자 등록번호 검증 로직 (0) | 2023.07.20 |
---|---|
[자바웹개발워크북] 10-1. AWS 환경구축 (0) | 2023.03.03 |
[자바웹개발워크북] 8-1. 소셜 로그인 (0) | 2023.02.15 |
[자바웹개발워크북] 8. 스프링시큐리티 (0) | 2023.02.13 |
[자바웹개발워크북] 7. 파일업로드 처리 (0) | 2023.02.09 |