서블릿 필터
- 서블릿 필터란 서블릿 실행 전에 실행되는 클래스들로, 디스패처 서블릿이 실행되기 전에 항상 실행된다.
- 구현된 로직에 따라 원하지 않는 HTTP 요청을 걸러낼 수 있으며, 걸러낸 HTTP는 거절된다.
- 서블릿 필터에서 전부 살아남은 HTTP 요청은 디스패처 서블릿으로 넘어와 컨트롤러에서 실행된다.
- 서블릿 필터를 구현하려면 HttpFilter 또는 Filter를 상속해 doFilter() 라는 메서드를 원하는대로 오버라이딩
- 서블릿 필터들은 FilterChain을 이용해 연쇄적으로 순서대로 실행되며, 각 필터는 다음으로 부를 Filter를 FilterChain 안에 갖고 있어 FilterChain을 통해 다음 필터를 실행할 수 있다.
스프링 시큐리티
스프링 시큐리티는 스프링 기반 어플리케이션의 권한과 인증, 인가 등의 보안을 담당하는 프레임워크로, 간단히 말하면 서블릿 필터의 집합이라고 할 수 있다.
스프링 시큐리티 프로젝트를 추가하면 스프링 시큐리티가 FilterChainProxy라는 필터를 서블릿 필터에 끼워넣고, FilterChainProxy 클래스 안에서 내부적으로 필터를 실행시키는데 이 필터들이 스프링이 관리하는 스프링 빈 필터이다.
스프링 시큐리티 인증 과정
- 사용자가 로그인 정보와 함께 인증 요청 (HttpRequest)
- AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken 객체 생성 (미인증 상태)
- AuthenticationManager 구현체인 ProviderManager에게 UsernamePasswordAuthenticationToken 객체 전달
- AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체 전달
- 실제 DB로부터 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보 전달
- 넘겨받은 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체 생성
- AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보 비교
- 인증이 완료되면 사용자 정보를 담은 Authentication 객체 반환
- AuthenticationFilter에 Authentication 객체가 반환
- Authentication 객체를 SecurityContext에 저장
더보기
OAuth 2.0 로그인을 사용하면 AuthenticationFilter 대신 OAuth2LoginAuthenticationFilter 호출
JWT 로그인 사용을 위해서 앞으로 OncePerRequestFiler를 상속받은 JwtAuthenticationFilter를 구현할 것
JWT 인증
의존성 추가
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
TokenProvider
사용자 정보를 받아 JWT를 생성하고 토큰을 검증하는 클래스
@Slf4j
@Service
public class TokenProvider{
private static final String SECRET_KEY = "NMA8JPctFuna59f5";
// JWT 라이브러리를 이용해 JWT 토큰 생성
public String create(UserEntity userEntity){
// 만료기한 1일
Date expiryDate = Date.from(Instant.now()
.plus(1, ChronoUnit.DAYS));
// JWT Token 생성
return Jwts.builder()
// header, secret_key
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
// payload
.setSubject(userEntity.getId())
.setIssuer("My app")
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.compact();
}
// 토큰을 디코딩 및 파싱하고 토큰의 위조 여부를 확인한 후, 사용자의 아이디를 리턴
public String validateAndGetUserId(String token){
// parseClaimsJws: Base64로 디코딩 및 파싱
// 헤더와 페이로드를 setSigningKey로 넘어온 시크릿을 이용해 서명한 후 token의 서명과 비교
// 위조되지 않았다면 페이로드 리턴, 위조라면 예외 날림
// userId가 필요하므로 getBody 호출
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}
JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
// 요청에서 토큰 가져오기
String token = parseBearerToken(request);
log.info("Filter is running...");
// 토큰 검사
// jwt 이므로 인가 서버에 요청하지 않고도 검증 가능
if (token != null && !token.equalsIgnoreCase("null")) {
// userId 가져오기
// 위조된 경우 예외 처리됨
String userId = tokenProvider.validateAndGetUserId(token);
log.info("Authenticated user Id : " + userId);
// 인증 완료
AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userId, // AuthenticationPrincipal
null, // 인증된 사용자의 정보, 보통 UserDetails 라는 오브젝트를 넣는다.
AuthorityUtils.NO_AUTHORITIES);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContextHolder 에 등록
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authentication);
SecurityContextHolder.setContext(securityContext);
}
} catch (Exception e) {
logger.error("Could not set user authentication in security context", e);
}
filterChain.doFilter(request, response);
}
private String parseBearerToken(HttpServletRequest request) {
// http 요청의 헤더를 파싱해 bearer 토큰 리턴
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
- HttpFilter 대신 OncePerRequestFilter를 상속하며, doFilter 대신 doFilterInternal을 오버라이딩 한다.
- 내부 토큰 인정 과정
- 요청의 헤더에서 Bearer 토큰을 가져온다. (parseBearerToken() 메서드)
- TokenProvider를 이용해 토큰을 인증하고 UsernamePasswordAuthenticationToken을 작성한다.
- UsernamePasswordAuthenticationToken에 사용자 인증 정보를 저장하고 SecurityContext에 인증된 사용자를 등록한다. (요청을 처리하는 과정에서 사용자 인증 여부나 인증된 사용자가 누군지 확인하기 위함)
요청에서 인증된 사용자 식별
- 스프링 시큐리티의 SecurityContext는 SecurityContextHolder의 createEmptyContext() 메서드를 이용해 생성
- 생성한 컨텍스트에 인증 정보인 authentication을 넣고 다시 SecurityContextHolder에 컨텍스트로 등록한다.
- SecurityContextHolder는 기본적으로 ThreadLocal에 저장되며, Thread 마다 하나의 컨텍스트를 관리할 수 있고 같은 스레드 내라면 어디에서든 접근할 수 있다.
- ThreadLocal에 저장된 오브젝트는 각 스레드별로 저장되고 마찬가지로 불러온 내 스레드에서 저장한 오브젝트만 불러올 수 있다.
- 스프링은 컨트롤러 메서드를 호출할 때 @AuthenticationPricipal 어노테이션이 있으면, SecurityContextHolder에서 SecurityContext::Authentication, 즉 UsernamePasswordAuthenticationToken 오브젝트를 가져온다.
- 이 오브젝트에서 AuthenticationPrincipal을 가져와 컨트롤러 메서드에 넘겨준다.
- AuthenticationPrincipal을 String 형의 오브젝트로 지정했으므로, @AuthenticationPrincipal의 형으로 String을 사용해야 한다.
WebSecurityConfig
- HttpSecurity는 스프링 시큐리티 설정을 위한 오브젝트로, 제공하는 빌더를 통해 다양한 설정을 할 수 있다.
- 서블릿 컨테이너에 서블릿 필터를 사용하라고 알려주는 설정 작업 또한 HttpSecurity를 이용한다.
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
// http 시큐리티 빌더
http.cors() // WebMvcConfig 에서 이미 설정했으르로 기본 cors 설정
.and()
.csrf()
.disable()
.httpBasic() // token을 사용하므로 basic 인증은 disable
.disable()
.sessionManagement() // session 기반이 아님을 선언
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/", "/auth/**").permitAll()
.anyRequest()
.authenticated();
// filter 등록
// 매 요청마다 CorsFilter 실행 후 jwtAuthenticationFiler 실행
http.addFilterAfter(jwtAuthenticationFilter, CorsFilter.class);
}
}
패스워드 암호화
- 패스워드 암호화를 위해서 스프링 시큐리티가 제공하는 BCryptPasswordEncoder를 사용한다.
- BCryptPasswordEncoder는 같은 값을 인코딩하더라도 할 때마다 값이 다르고, 패스워드에 랜덤하게 의미없는 값을 붙여 결과를 생성한다.
- 이런 의미 없는 값을 보안 용어로 Salt라고 하고, Salt를 붙여 인코딩하는 것을 Salting이라고 한다.
- 사용자에게 받은 패스워드를 인코딩해도 데이터베이스에 저장된 패스워드와는 다를 확률이 높기 때문에, BCryptPasswordEncoder는 어떤 두 값의 일치 여부를 알려주는 메서드인 matchers()를 제공한다.
Contoller
@PostMapping("/signin")
public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
UserEntity user = userService.getByCredentials(
userDTO.getEmail(),
userDTO.getPassword(),
passwordEncoder);
if (user != null) {
// 토큰 생성
final String token = tokenProvider.create(user);
final UserDTO responseUserDTO = UserDTO.builder()
.email(user.getEmail())
.id(user.getId())
.token(token)
.build();
return ResponseEntity.ok().body(responseUserDTO);
} else {
ResponseDTO responseDTO = ResponseDTO.builder()
.error("Login failed")
.build();
return ResponseEntity.badRequest().body(responseDTO);
}
}
Service
@Transactional(readOnly = true)
public UserEntity getByCredentials(final String email, final String password, final PasswordEncoder encoder) {
final UserEntity originalUser = userRepository.findByEmail(email);
if (originalUser != null && encoder.matches(password, originalUser.getPassword())) {
return originalUser;
}
return null;
}
'Spring > Spring Security' 카테고리의 다른 글
스프링 시큐리티 설정과 로그인 구현 (0) | 2022.01.25 |
---|---|
OAuth2 로그인 인증 (0) | 2022.01.21 |