티스토리 뷰
Spring Advanced : FootBall Club example REST 작성 - 3. JWT 보안토큰 설정
Korean Eagle 2020. 7. 6. 05:180. 이 포스트는 이전 Fooball club예제를 REST로 다시 작성하는 시리즈의 일부이다.
1. 적용된 테크닉
1-1 JWT 생성 및 검증
1-2 OncePerRequestFilter 생성 및 등록
1-3 AuthenticationEntryPoint 작성
2. 이 포스트에서 할 내용
2-1 보안 설정하기
2-2 인증용 컨트롤러 만들기
2-3 JWT 유틸리티 생성하기
2-3 인증용 endpoint 작성하기
2-4 JWT 검증용 필터 작성하기
3. 보안설정하기
3-1 지난 포스트에서 안보이던 JwtRequestFilter가 보인다. 이것은 Request마다 JWT 검증용으로 사용한다.
3-2 필요한 곳에 인증을 적용하기 위해 skills, players, teams에 대한 접근을 USER권한 이있는 사람으로 한정하였다.
3-3 JWT 토큰 방식은 서버에 세션을 관리할 필요가 없으므로 STATELESS방식으로 설정해야 한다.
3-4 JWT 검증 필터를 언제 처리할지를 지정해 주어야 한다.
3-4-1 지정해 주지 않으면 보안 경로 접근 시 검증 절차도 수행되지 않고, 접근 권한 없음 에러를 볼 수 있다.
3-5 인증오류가 발생할 때 처리할 AuthenticationEntryPoint도 생성하였다.
3-6 스프링 부트에 자동생성이 수동생성으로 변경된 AuthenticationManager를 @Bean으로 설정해야 한다.
3-6-1 이렇게 하면 보안 UserDetailsService가 연결된 local ProviderManager가 생성되어 외부에서 사용가능해 진다.
package pe.pilseong.footballserver.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import pe.pilseong.footballserver.filter.JwtRequestFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Autowired
private JwtAuthEntryPoint unauthorizedHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().antMatchers("/api/skills/**", "/api/players/**", "/api/teams/**").hasRole("USER")
.antMatchers("/test**", "/api/authenticate").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
3-7 AuthenticationEntryPoint 클래스
3-7-1 JWT 검증을 통과하지 못하는 경우와 login을 실패하는 경우 클래스의 commence가 호출된다.
3-7-2 다시 말하면 AuthenticationException이 발생하는 경우에 실행된다.
package pe.pilseong.footballserver.security;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.json.JSONObject;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
log.error("Unauthorized error. Message - {}", authException.getMessage());
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
String msg = new JSONObject()
.put("timestamp", LocalDateTime.now())
.put("status", HttpStatus.UNAUTHORIZED)
.put("error", authException.getMessage()).toString();
log.info(msg);
response.getWriter().write(msg);
}
}
3-7-3 위 소스코드의 JSONObject를 사용하기 위해서 아래 의존성을 추가한다.
3-7-3-1 JSONObject는 Map을 key-value 매핑을 JSON으로 변환해 준다.
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20200518</version>
</dependency>
4. 이제 인증 URL로 들어오는 인증 수행을 위한 컨트롤러가 필요하다.
4-1 아래 코드로 들어오는 검증 요청은 AuthenticationRequest에 담아서 받는다.
4-2 이 검증 요청 객체는 username, password 항목이 있고 둘 다 @NotEmpty로 처리하여 미리 에러를 방지한다.
4-2-1 @Valid를 사용하여 검증해야 한다. 다양한 에러에 따른 예외처리는 필수이다. 여기선 이미 다 되어 있다.
4-3 username, password가 일치하지 않으면 BadCredentialsException를 발생시켜
4-3-1 AuthenticationEntryPoint에서 처리한다.
4-3-2 BadCredentialsException은 AuthenticationException을 상속하고 있다.
4-4 검증을 위한 인증 관리자는 미리 설정파일에서 설정해두었으니 @Autowired로 주입받는다.
4-4-1 주입 받은 인증 관리자는 설정파일에서 인증로직이 연결되어 authenticate 메소드만 수행하면 자동처리된다.
4-5 예외가 발생하지 않고 인증을 예외없이 통과하면 JWT 토큰을 생성하여 클라이언트에게 반환한다.
package pe.pilseong.footballserver.controller;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import pe.pilseong.footballserver.dto.AuthenticationRequest;
import pe.pilseong.footballserver.dto.AuthenticationResponse;
import pe.pilseong.footballserver.repository.UserRepository;
import pe.pilseong.footballserver.util.JwtUtil;
@RestController
@RequestMapping("/api")
@Slf4j
public class AuthenticationController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate(
@Valid @RequestBody AuthenticationRequest request) {
log.info("authenticate :: " + request.toString());
try {
this.authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
));
} catch (Exception e) {
throw new BadCredentialsException("Incorrect username or password" + e);
}
UserDetails userDetails = this.userRepository.findByUsername(request.getUsername());
String jwt = jwtUtil.generateToken(userDetails);
log.info("authenticate in AuthenticationController jwt is :: " + jwt);
return ResponseEntity.ok().body(new AuthenticationResponse(jwt));
}
}
4-6 인증용 AuthenticationRequest, AuthenticationResponse
package pe.pilseong.footballserver.dto;
import javax.validation.constraints.NotEmpty;
import lombok.Data;
@Data
public class AuthenticationRequest {
@NotEmpty
private String username;
@NotEmpty
private String password;
}
package pe.pilseong.footballserver.dto;
import lombok.Data;
@Data
public class AuthenticationResponse {
private final String jwt;
}
* 한참지나 읽어보다보니 JwtUtil 설명이 빠져있다.
4-5/2 아래의 JwtUtil은 Jwt token을 생성, 검증하는 기능을 가진다.
package pe.pilseong.footballserver.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtUtil {
private String SECRET_KEY = "secret";
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
5. JWT를 생성하여 클라이언트에게 보냈으니 이제 매 Request마다 JWT토큰을 검증할 filter가 필요하다.
5-1 filter를 사용하는 이유는 모든 요청에 대해서 JWT검증이 필요하고 그 시기가 가장 앞단에 위치하기 때문이다.
5-2 모든 요청마다 실행되어야 하므로 OncePerRequestFilter를 상속하여 작성한다.
5-3 메소드는 doFilterInternal를 Override(재작성) 하면 되고 검증을 위해 필요한 건 JWT 토큰과 JWT 유틸이다.
5-3-1 JWT유틸은 주입받을 수 있고, JWT는 Request header에서 얻을 수 있다.
5-3-2 형식이 request 헤더에 Authorization 속성, 값은 Bearer jwt값 이런 형식이다.
5-4 로직은
5-4-1 header에서 Authorization 속성이 있는지 확인하고
5-4-2 있는 경우는 jwt에서 username을 추출하여 그 이름으로 데이터베이스를 검색하여 찾은 결과를 비교한다.
5-4-2-1 단순히 반환받은 username과 JWT의 username이 동일한지와 기간이 남아있는 토큰인지를 체크한다.
5-4-3 사실 토큰의 값을 추출할 때 이미 검증이 완료된 것이다. 추출할 때 복호화 로직과 secret 텍스트를 사용한다.
5-5 검증에 통과한 경우 SecurityContext에 Authentication을 저장하여 인증을 마친다.
5-6 여기 나온 인증관련 내용들이 이해 어려우면 아래 링크를 참조한다.
5-7 이렇게 filter로 구현하면 토큰 만료시 발생하는 ExpiredJwtException예외를 처리하기 힘들다는 점이다.
5-7-1 @ControllerAdvice는 DispatcherServlet 내부의 @Controller의 수식을 받는 클래스에서 발생한 예외만 다룬다.
5-7-2 이 경우는 filter 내부에 json 예외처리를 하던가 HandlerInterceptor를 사용하는 것이 좋다.
5-7-3 위치 상 filter내부에서 예외처리하는 것이 제일 좋은 방식처럼 보인다. HandlerInterceptor는 너무 뒤에 있다.
5-7-4 위치를 정확하게 아는 게 중요하다.
5-8 스프링 filter
5-9 스프링 HandlerInterceptor
5-10 현재 구조와 유사한 도식
5-11 에러처리를 내부적으로 하기 위해서 필터 코드 전체를 try - catch로 감싸고 있다.
5-11-1 보통 많이 나는 에러는 ExpiredJwtException 같은 대부분의 JwtException이다.
5-11-2 여기서 에러 기본적인 예외처리를 하지만 실제 에러 처리 부분은 AuthenticationEntryPoint에 넘긴다.
5-11-3 이렇게 하면 검증오류가 발생하여 AuthenticationException이 발생하게 된다.
package pe.pilseong.footballserver.filter;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.ExpiredJwtException;
import lombok.extern.slf4j.Slf4j;
import pe.pilseong.footballserver.util.JwtUtil;
@Slf4j
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
try {
log.info("doFilterInternal in JwtRequestFilter start");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
username = this.jwtUtil.extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.info("doFilterInternal in JwtRequestFilter before :: " + token.toString());
SecurityContextHolder.getContext().setAuthentication(token);
log.info("doFilterInternal in JwtRequestFilter after :: " + token.toString());
}
}
} catch (ExpiredJwtException e) {
log.error("JWT had expired");
} catch (Exception e) {
log.error("Error occurred while jwt verification");
}
log.info("Test before chain");
filterChain.doFilter(request, response);
}
}
6. 이제 보안설정까지 마무리 되었다. 하지만 위와 같이 작성하고 실행하면 동작하지 않는다.
6-1 Role을 가지고 오는 데 lazy로딩을 하는데 세션이 없다고 할 것이다.
6-2 User와 Role 관계 Many to Many의 기본 로딩방식이 lazy라서 발생한 부분인데 eager로 바꾸면 간단히 해결된다.
6-2-1 lazy를 그대로 두고 싶으면 @Transaction을 확장해야 하는데 별로 좋은 방법으로 보이지는 않는다.
package pe.pilseong.footballserver.model;
import java.util.Collection;
import java.util.Set;
import javax.persistence.Column;
import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Entity
@Table(name = "users")
@Data
@EqualsAndHashCode(callSuper=false)
public class User extends AbstractEntity implements UserDetails {
private static final long serialVersionUID = 1L;
@Column
private String username;
@Column
private String password;
@Column
private String fullname;
@Column(name = "phone_number")
private String phoneNumber;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles;
@Embedded
@Column
private Address address;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
6-3 한 가지 문제가 더 있는데 Lombok에서 @Data를 사용하는데 User, Role은 recursive관계라서 조심해야 한다.
6-3-0 실행해보면 Stackoverflow가 발생하면서 에러가 엄청나게 올라올 것이다. 대부분의 경우가 Recusive 문제이다.
6-3-1 @Data 적용시 부모 클래스 때문에 발생하는 warning을 처리하기 위해서
6-3-2 @EqualsAndHashCode(callSuper=false)를 붙였는데, 양쪽에 있는 경우 Equals 메소드 만들면서 loop이 생긴다.
6-3-3 한쪽을 끊어 주어야 한다. 여기서는 Role에서만 삭제해 주었다.
6-4 하이버네이트를 다룰 때 loop문제는 정말 주의해야 한다.
6-5 Json변환 시에도 문제가 발생할 수 있음을 생각하고 작업해야 한다.
package pe.pilseong.footballserver.model;
import java.util.Set;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.Table;
import org.springframework.security.core.GrantedAuthority;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "roles")
@Getter
@Setter
public class Role extends AbstractEntity implements GrantedAuthority {
private static final long serialVersionUID = 1L;
private String name;
@ManyToMany
@JoinTable(
name = "users_roles",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private Set<User> users;
@Override
public String getAuthority() {
return name;
}
}
7. 결과
7-1 여기까지 정상적으로 코딩이 되었으면, /api/authenticate으로 인증을 해서 jwt를 받을 수 있고,
7-2 받아온 jwt토큰으로 보안 설정된 url의 리소스 접근이 가능할 것이다.
8. 인증 절차에 대한 생각
8-1 허용된 uri - login 같이 authenticaton을 시도하고 성공한 경우 JWT를 발급받는다.
8-2 보안적용된 url - jwt를 filter에서 검증하고 검증되면 해당 보안 url 접근 가능
8-3 filter에서 login과 authentication을 모두 처리하면 좋겠지만 가능한지는 모르겠다.
9. 결과적으로 아래의 구조처럼 돌아가는데 인터넷을 찾아봐도 대부분 유사하게 작성하는 것 같다.
'Demos > Football Club' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 도커 개발환경 참고
- AWS ARN 구조
- Immuability에 관한 설명
- 자바스크립트 멀티 비동기 함수 호출 참고
- WSDL 참고
- SOAP 컨슈머 참고
- MySql dump 사용법
- AWS Lambda with Addon
- NFC 드라이버 linux 설치
- electron IPC
- mifare classic 강의
- go module 관련 상세한 정보
- C 메모리 찍어보기
- C++ Addon 마이그레이션
- JAX WS Header 관련 stackoverflow
- SOAP Custom Header 설정 참고
- SOAP Custom Header
- SOAP BindingProvider
- dispatcher 사용하여 설정
- vagrant kvm으로 사용하기
- git fork, pull request to the …
- vagrant libvirt bridge network
- python, js의 async, await의 차이
- go JSON struct 생성
- Netflix Kinesis 활용 분석
- docker credential problem
- private subnet에서 outbound IP 확…
- 안드로이드 coroutine
- kotlin with, apply, also 등
- 안드로이드 초기로딩이 안되는 경우
- navigation 데이터 보내기
- 레이스 컨디션 navController
- raylib
- XML
- jsp
- Angular
- Spring
- 하이버네이트
- form
- one-to-many
- 스프링부트
- crud
- spring boot
- Validation
- Spring Security
- mapping
- Security
- 설정하기
- 매핑
- login
- Many-To-Many
- 상속
- 스프링
- one-to-one
- MYSQL
- 자바
- hibernate
- Rest
- WebMvc
- 로그인
- 설정
- 외부파일
- RestTemplate