이노캠에서 주어진 과제가 요구하는 것은 JWT 토큰 방식을 사용하면서, Spring Security 로 이를 인증/인가 하는 것이었다. (참고: 서버 인증 - 토큰 기반 인증 방식 (JWT))
Spring Security - Session / Cookie 방식 에서 정리해둔 Spring Security 의 동작 과정과 흡사하다.
이에 대한 구현 과정을 정리해보고자 한다.
build.gradle > dependencies
implementation 'org.springframework.boot:spring-boot-starter-security'
User 엔티티
@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
public User(String username, String password, UserRoleEnum role) {
this.username = username;
this.password = password;
this.role = role;
}
}
JwtUtil 클래스
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
/* 1. JWT 데이터 준비하기 */
public static final String AUTHORIZATION_HEADER = "Authorization"; // Header KEY 값
public static final String AUTHORIZATION_KEY = "auth"; // 사용자 권한 값의 KEY
public static final String BEARER_PREFIX = "Bearer "; // Token 식별자
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 토큰 만료시간 : 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey (application.properties 에 추가해둔 값)
private String secretKey; // 그 값을 가져와서 secretKey 변수에 넣는다
private static Key key; // Secret key 를 담을 변수
private static final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 사용할 알고리즘 선택
@PostConstruct // 한 번만 받으면 값을 사용할 때마다, 매번 요청을 새로 호출하는 것을 방지
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
/* 2. JWT 토큰 생성 */
// 인증된 토큰을 기반으로 JWT 토큰을 발급
public static String createToken(String username, UserRoleEnum role) {
Date date = new Date();
// 암호화
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID). 여기에선 username 을 넣음
.claim(AUTHORIZATION_KEY, role) // 사용자 권한 (key, value)
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 : 현재시간 date.getTime() + 위에서 지정한 토큰 만료시간(60분)
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘 (Secret key, 사용할 알고리즘 종류)
.compact();
}
// 3. header 에서 JWT 가져오기
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
/* 4. JWT 토큰 검증 */
// 토큰의 만료, 위/변조 를 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
/* 5. JWT 토큰에서 사용자 정보 가져오기 */
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
@Getter
@NoArgsConstructor
public class LoginRequestDto {
private String username;
private String password;
}
username 과 password 로 로그인 요청을 보낸다.
로그인 요청은 가장 먼저 Application Filters 로 들어오고, 그 필터들 中 AuthenticationFilter 로 들어온다.
그리고 AuthenticationFilter 필터들 中 최종적으로 UsernamePasswordAuthenticationFilter 에 도착하게 된다.
이를 위해 AuthenticationFilter 가 UsernamePasswordAuthenticationFilter 를 상속받도록 했다.
(JWT 토큰을 이용하므로, 클래스 명을 JwtAuthenticationFilter 로 했다.)
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login"); // 1.
}
// 로그인 시, username 과 password 를 바탕으로 UsernamePasswordAuthenticationToken 을 발급
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class); // 2.
return getAuthenticationManager().authenticate( // 3.
new UsernamePasswordAuthenticationToken(requestDto.getUsername(), requestDto.getPassword(),null) // 4.
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
// 로그인 성공 시
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername(); // username
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole(); // role
String token = jwtUtil.createToken(username, role); // 5.
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token); // 6.
}
// 로그인 실패 시
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
1.
2.
3.
4.
5.
6.
이를 통해, 로그인 시 해다 사용자에게 JWT 토큰이 생성된다.
즉, 인증
이 완료 된 것이다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService { // 1.
private final UserRepository userRepository;
// DB 에 저장된 사용자 정보와 일치하는지 여부를 판단
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username).orElseThrow(
() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user)
}
}
1.
2.
@Getter
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user; // 1.
public User getUser() { // 2.
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
// 사용자의 권한을 GrantedAuthority 로 추상화 및 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { // 3.
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
인증된 회원의 상세정보를 담은 UserDetails 객체
UsernamePasswordAuthenticationToken (Authentication 객체를 구현한)을 생성하기 위해 사용된다
1.
2.
3.
인증이 완료된 사용자(즉, 회원)은 게시글 또는 댓글을 작성/수정/삭제 하기 위해서는 '인가'가 떨어져야만 가능하다.
@Slf4j(topic = "JWT 검증 및 인가")
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getJwtFromHeader(req); // header 에서 JWT 가져오기
if (StringUtils.hasText(tokenValue)) {
if (!jwtUtil.validateToken(tokenValue)) { // JWT 토큰 검증
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue); // JWT 토큰에서 사용자 정보 가져오기
try {
setAuthentication(info.getSubject()); // 인증 처리
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext(); // 1.
Authentication authentication = createAuthentication(username); // 2.
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context); // 4.
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); // 3.
}
}
1.
2.
3.
4.
WebSecurityConfig 클래스
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
// 1.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 2.
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
// 3.
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
// 4. SecurityFilterChain 빈
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 5.
http.csrf((csrf) -> csrf.disable());
// 6.
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/auth/**").permitAll() // '/api/auth/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 7.
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
1.
2.
3.
4.
5.
6.
7.
필터 관리
JWT 인증/인가를 사용하기 위한 설정
http.addFilterBefore( , )
앞의 필터를 수행한 후, 뒤의 필터를 수행할 것이라는 의미
순서
SecurityFilterChain
작동하나요? 매요청마다 컨텍스트 홀더를 어떻게 세팅하는거지..... sessioncontextpersistencefilter 은 빠져있어서요