- 스프링 시큐리티
- 서블릿 필터
- JWT 인증 로직
- 패스워드 암호화
~이전 게시물에서~
로그인 여부를 저장하지 않는 문제
→ 스프링 시큐리티와 JWT토큰 사용하여 해결하기
어떻게 구현하묘..?
제일 간단하게는..
그런데 이렇게 했다가 API가 100개라면..? 5개도 귀찮아서 하기시름
그래서 스프링 시큐리티를 쓴다..!
스프링 시큐리티 코드를 작성하고 해당 코드가 모든 API 수행하기 전에 실행되도록 해준다~~
{헤더}.{페이로드}
를 전자서명하면 A라는 결과를 받는다{헤더}.{페이로드}.A
를 Base64로 인코딩하여 토큰 리턴하기https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt
build.gradle의 dependencies에 추가해준다.
JWT 생성해주는 라이브러리를 사용한다
package com.example.demo.security;
// import ...
@Slf4j
@Service
public class TokenProvider {
// 시크릿 키 눈감고 키보드 두들기면 됨
private static final String SECRET_KEY = "AS1SLsDFsdVLa5SD";
// 사용자에게 할당될 토큰 생성해주는 create 메서드
public String create(UserEntity userEntity) {
// 유효기간은 하루
Date expDate = Date.from(
Instant.now()
.plus(1, ChronoUnit.DAYS)
);
// 토큰 생성
return Jwts.builder()
// header 내용
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
// payload 내용
.setSubject(userEntity.getId()) // 토큰 소유자
.setIssuer("JUEON^-^v") // 토큰 발행자
.setIssuedAt(new Date()) // 토큰 발행일
.setExpiration(expDate) // 토큰 만료일
.compact();
}
public String validateAndGetUserId(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}
validateAndGetUserId
parseClaimsJws
가 Base64로 디코딩 및 파싱 수행한다. setSigningKey()
의 매개변수로 넘어온 시크릿 키를 이용하여 서명한다.parseClaimsJws()
의 매개변수로 전달된 token이 위조되지 않은 토큰이라면 Claims(페이로드)를 리턴한다.getBody()
호출이제 로그인 시 TokenProvider를 사용하여 토큰을 생성한 뒤 UserDTO에 반환하면 된다.
// 의존주입
@Autowired
private TokenProvider tokenProvider;
// ...
@PostMapping("/signin")
public ResponseEntity<?> authenticate(@RequestBody UserDTO userDTO) {
UserEntity user = userService.getByCredentials(
userDTO.getEmail(), userDTO.getPassword());
if (user != null) {
// 토큰 생성
final String token = tokenProvider.create(user);
final UserDTO responseUserDTO = UserDTO.builder()
.email(user.getEmail())
.id(user.getId())
// 응답으로 토큰 전송
.token(token)
.build();
// 생략
}
토큰 생성 완.
로그인 시 생성한 토큰 리턴 완.
이제 API마다 인증하는 거 구현하면 된다... 🫠
위의 순서가 API 실행될 때마다 수행되어야 사용자 인증이 완료되는 것이다.
이렇게 API 실행마다 토큰인증을 해주려면 컨트롤러의 메서드 첫 부분마다 인증코드를 작성해야한다.
→ 서블릿 필터를 사용하여 해결한다
서블릿 실행 전에 실행되는 클래스
스프링의 서블릿 이름은 디스패처 서블릿인데, 서블릿 필터는 디스패처 서블릿이 실행되기 전 항상 실행된다. 스프링 시큐리티는 일종의 서블릿 필터의 집합이다.
서블릿 필터는 HttpFilter나 Filter를 상속하는 클래스이다. 이를 상속해서 doFilter()
메서드를 오버라이딩하면 된다.
오버라이딩하여 구현을 끝낸 후에는 서블릿 컨테이너가 필터클래스를 사용하도록 web.xml같은 설정 파일에 경로를 지정 해줘야 한다. 일일히 하면 ㄱ ㅐ고생이다.. 스프링 시큐리티가 해준다^^!
스프링 시큐리티를 추가하면 스프링 시큐리티가 FilterChainProxy라는 필터를 서블릿 필터에 추가한다. 이 필터 클래스는 내부적으로 필터를 실행시키는데 이 내부의 필터들이 스프링이 관리하는 스프링 빈 필터이다.
스프링 시큐리티를 사용하면 HttpFilter나 Filter 대신 OncePerRequestFilter를 상속하고, web.xml 대신 WebSecurityConfigurerAdapter 클래스를 상속하여 필터를 설정한다.
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.6.8'
한 요청 당 한번씩만 인증하면 되기 때문에 OncePerRequestFilter를 상속하는 클래스를 작성해줄 것이다.
parseBearerToken()
: 요청의 헤더에서 Bearer 토큰 추출하기createEmptyContext()
메서드를 사용하여 SecurityContext 생성💡 SecurityContextHolder는 ThreadLocal에 저장되기 때문에 스레드마다 하나의 컨텍스트 관리가 가능하며,
같은 스레드 내에 있다면 어디서든 접근이 가능하다.
<package com.example.demo.security;
// import ...
@Slf4j
@Component
public class AuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// request에서 토큰 파싱하기
String token = parseToken(request);
// 토큰이 존재할 경우 유효성 검사하기
// equalsIgnoreCase 대소문자 구분하지 않고 문자열을 검사한다.
if (token != null && !token.equalsIgnoreCase("null")) {
// 토큰 인증과정을 거쳐서 유저의 id 추출
String userId = tokenProvider.validateAndGetUserId(token);
// 인증된 토큰을 SecurityContext에 등록해줘야 인증된 사용자를 인식한다.
AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userId, null, AuthorityUtils.NO_AUTHORITIES);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(securityContext);
}
} catch (Exception ex) {
logger.error(ex);
}
filterChain.doFilter(request, response);
}
// HTTP requset의 헤더부분을 파싱하여 Bearer 토큰을 리턴하는 메서드
private String parseToken(HttpServletRequest request) {
String tokenFromReq = request.getHeader("Authorization");
if (StringUtils.hasText(tokenFromReq) && tokenFromReq.startsWith("Bearer ")) {
return tokenFromReq.substring(7);
}
return null;
}
}
서블릿 필터를 사용하려면
package com.example.demo.config;
// import ...
@Slf4j
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@Configuration
public class WebSecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors() // WebMvcConfig에서 이미 설정했으므로 기본 cors 설정.
.and()
.csrf()// csrf는 현재 사용하지 않으므로 disable
.disable()
.httpBasic()// token을 사용하므로 basic 인증 disable
.disable()
.sessionManagement() // session 기반이 아님을 선언
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests() // /와 /auth/** 경로는 인증 안해도 됨.
.antMatchers("/", "/auth/**").permitAll()
.anyRequest() // /와 /auth/**이외의 모든 경로는 인증 해야됨.
.authenticated();
// filter 등록.
// 매 리퀘스트마다
// CorsFilter 실행한 후에
// jwtAuthenticationFilter 실행한다.
http.addFilterAfter(
jwtAuthenticationFilter,
CorsFilter.class
);
return http.build();
}
}