Project/React+Java
6. Spring Boot + Spring Security + JWT + access token
건담아빠
2022. 7. 22. 14:29
https://github.com/dchkang83/project-board
GitHub - dchkang83/project-board
Contribute to dchkang83/project-board development by creating an account on GitHub.
github.com
jwt 및 security를 최신 버전으로 설정하다 보니 deprecated 된 class 및 function 들이 많아서 설정하는데 애를 먹었다.!!
자세한 소스들은 깃을 참조하기 바란다.
1. boot starter security - 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
위와 같이 의존성만 추가하여도 시큐리티의 기본설정을 따르게 됨으로 아래 이미지와 같이 로그인 화면이 뜬다.
(로그인 폼 참의 URL도 기본설정을 따름으로 이후에 config 설정을 통해서 변경하면 된다.)
현재 상태에서 로그인 하려면 Username 은 user로 입력하고, Password는 아래 사진과 같이 어플리케이션 시작 로그에 표시되는 패스워드를 사용하면 된다.
2. JWT - 의존성 추가
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
3. JWT key 설정
# application.yml
jwt:
secret: ThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExample
# WeSecurityConfig.java
@Value("${jwt.secret}")
private String secret;
@Bean
public JwtTokenProvider jwtTokenProvider() {
return new JwtTokenProvider(secret);
}
@Bean
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
// login 주소가 호출되면 인증 및 토큰 발행 필터 추가
http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
// jwt 토큰 검사
http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
...
}
4. 주요 소스 정리
- WebSecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class WebSecurityConfig {
private final CorsFilter corsFilter;
private final UserRepository userRepository;
private final AuthenticationConfiguration authenticationConfiguration;
@Value("${jwt.secret}")
private String secret;
@Bean
public AuthenticationManager authenticationManager() throws Exception
{
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtTokenProvider jwtTokenProvider() {
return new JwtTokenProvider(secret);
}
@Bean
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilter(corsFilter) // Controller 어노테이트 @CrossOrigin(인증 X), 시큐리티 필터에 등록 인증(O)
.formLogin()
// .loginProcessingUrl("/login") // /login 주소가 호출이 되면 시큐리티가 낚아채서 대신 로그인을 진행해준다.
.and()
.httpBasic().disable()
.authorizeRequests()
// .antMatchers("/signin").permitAll() // 컨트롤러에서 인증 하는 부분 테스트
.antMatchers("/api/v1/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**")
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll();
// login 주소가 호출되면 인증 및 토큰 발행 필터 추가
http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
// jwt 토큰 검사
http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
// TODO. 왜 추가 했는지 잘 기억이가 안남. 확인이 필요함
// http.authenticationProvider(authenticationProvider());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
return new PrincipalDetailsService(userRepository);
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}
- JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager; // @Autowired
private final JwtTokenProvider jwtTokenProvider;
// /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("{} - attemptAuthentication -> 로그인 시도중", this.getClass());
// request에 있는 username과 password를 파싱해서 자바 Object로 받기
ObjectMapper om = new ObjectMapper();
try {
/*
log.info(request.getInputStream().toString());
BufferedReader br = request.getReader();
String input = null;
while((input = br.readLine()) != null) {
log.info(input);
}
*/
User user = om.readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
// PrincipalDetailsService의 loadUserByUsername 함수가 실행된 후 정상이면 authentication이 리턴됨
// DB에 있는 username과 password가 일치한다.
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 로그인이 되었다는 뜻.
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
log.info(principalDetails.getUser().getUsername()); // 로그인 정상적으로 되었다는 뜻
// authentication 객체가 session 영역에 저장을 해야하고 그방법이 return 해주면 됨.
// 리턴의 이유는 권한 관리를 security가 대신 해주기 때문에 편하려고 하는거임
// 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session에 넣어준다.
return authentication;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
// attemptAuthentication실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됨
// JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해주면 됨
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("{} - successfulAuthentication -> 인증 완료", this.getClass());
String jwtToken = jwtTokenProvider.generateToken(authResult);
log.info("jwtToken : {}", jwtToken);
response.addHeader("Authorization", "Bearer " + jwtToken);
}
}
- JwtAuthorizationFilter
// 시큐리티가 filter 가지고 있는데 그 필터중에 BasicAuthenticationFilter 라는 것이 있음.
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음.
// 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탐
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
// public JwtAuthorizationFilter(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) {
// this.userRepository = userRepository;
// this.jwtTokenProvider = jwtTokenProvider;
// }
/**
* 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게됨
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("{} - successfulAuthentication -> 인증이나 권한이 필요한 주소 요청이 됨", this.getClass());
String bearerToken = request.getHeader("Authorization");
if (bearerToken == null || bearerToken.startsWith("Bearer") == false) {
chain.doFilter(request, response);
return;
}
String jwtToken = bearerToken.substring("Bearer ".length());
String username = jwtTokenProvider.getUsernameFromJWT(jwtToken);
// 서명이 정상적으로 됨
if (username != null) {
User userEntity = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
// Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어 준다.
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
- JwtTokenProvider
@Slf4j
public class JwtTokenProvider {
// 토큰 유효시간
// private final int JWT_EXPIRATION_MS = 604800000;
private final int JWT_EXPIRATION_MS = 60000 * 1; // 만료 시간 세팅 : 60000 (1분) * 10 => 10분
private final Key key;
public JwtTokenProvider(String secret) {
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
this.key = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
/**
* jwt 토큰 생성
*
* @param authentication
* @return
*/
public String generateToken(Authentication authentication) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);
// Date expiryDate = new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS);
// // 만료 시간 세팅 : 60000 (1분) * 10 => 10분
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
/*
* https://velopert.com/2389
* iss: 토큰 발급자 (issuer)
* sub: 토큰 제목 (subject)
* aud: 토큰 대상자 (audience)
* exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370)
* 언제나 현재 시간보다 이후로 설정되어있어야합니다.
* nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념입니다. 여기에도 NumericDate 형식으로 날짜를 지정하며,
* 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다.
* iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다.
* jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. (id)
*/
// 표준 클레임 셋팅
JwtBuilder builder = Jwts.builder()
.setId(String.valueOf(principalDetails.getUser().getUserNo())) // jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다.
.setIssuedAt(now) // iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. - 현재시간 기반으로 생성
.setSubject(principalDetails.getUser().getUsername()) // sub: 토큰 제목 (subject) - 사용자
.setIssuer("gundam.com") // iss: 토큰 발급자 (issuer)
.signWith(key, SignatureAlgorithm.HS512) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅
.setExpiration(expiryDate) // exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370), 언제나 현재 시간보다 이후로 설정되어있어야합니다.
;
return builder.compact();
}
/**
* Jwt 토큰에서 유져이름 추출
*
* @param jwtToken
* @return
*/
public String getUsernameFromJWT(String jwtToken) {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwtToken);
return claims.getBody().getSubject();
}
}
9. 이슈
9.1. WebSecurityConfigurerAdapter deprecated (Spring Security - How to Fix WebSecurityConfigurerAdapter Deprecated)
참조: https://www.codejava.net/frameworks/spring-boot/fix-websecurityconfigureradapter-deprecated