기본으로 제공되는 Authentication 구현체인 UsernamePasswordToken을 사용한 토큰 인증 객체에 대한 시큐리티 필터 커스터마이징 프로젝트의 구현은 다음과 같다.
GenericFilterBean을 상속하는 커스터마이징 필터 클래스를 정의하여 doFilter라는 메소드를 오버라이딩하는데 구현되어야 하는 기능은 다음과 같다.
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
String token = getToken(request);
if (token != null) {
try {
Jwt.Claims claims = verify(token);
log.debug("Jwt parse result: {}", claims);
String username = claims.username;
List<GrantedAuthority> authorities = getAuthorities(claims);
if (isNotEmpty(username) && authorities.size() > 0) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.warn("Jwt processing failed: {}", e.getMessage());
}
}
} else {
log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
SecurityContextHolder.getContext().getAuthentication());
}
chain.doFilter(request, response);
}
완성된 필터는 반드시 SecurityContextPersistenceFilter 필터 뒤에 동작할 수 있도록 설정해야 한다.
@Configuration
public class SecurityConfiguration {
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
Jwt jwt = getApplicationContext().getBean(Jwt.class);
return new JwtAuthenticationFilter(jwtConfigure.getHeader(), jwt);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/me").hasAnyRole("USER","ADMIN")
.anyRequest().permitAll()
.and()
.addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class);
return http.build();
}
}
기본으로 제공되는 Authentication 구현체가 아닌 직접 커스터마이징한 JwtAuthenticationToken을 사용한 필터 커스터마이징은 이전에 하던 커스터마이징 작업에서 추가적인 인증 객체와 해당 객체를 인증할 수 있는 Provider 클래스 구현을 추가하면 사용이 가능하다.
기존 UsernamePasswordAuthenticationToken 대신 AbstractAuthenticationToken를 상속한 JwtAuthenticationToken 구현체로 교체하고 사용자의 principal 타입으로 사용되는 User 대신 JwtAuthentication를 만든다.
public class JwtAuthentication {
public final String token;
public final String username;
JwtAuthentication(String token, String username) {
checkArgument(isNotEmpty(token), "token must be provided.");
checkArgument(isNotEmpty(username), "username must be provided.");
this.token = token;
this.username = username;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
.append("token", token)
.append("username", username)
.toString();
}
}
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private String credentials;
public JwtAuthenticationToken(String principal, String credentials) {
super(null);
super.setAuthenticated(false);
this.principal = principal;
this.credentials = credentials;
}
JwtAuthenticationToken(Object principal, String credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
super.setAuthenticated(true);
this.principal = principal;
this.credentials = credentials;
}
}
AbstractUserDetailsAuthenticationProvider를 상속하는 JwtAuthenticationProvider 클래스를 정의하고 supports 메소드와 authenticate 메소드를 오버라이딩하는데 구현되어야 하는 각 메소드의 기능은 다음과 같다.
supports 메소드는 커스터마이징한 인증 객체와 관련된 클래스에 대한 인증 가능 여부를 반환해야 한다.
authenticate 메소드는 미인증된 객체에 대해 인증 서비스를 거쳐 검증한 후 인증된 객체로 변환해서 반환해야 한다.
@Override
public boolean supports(Class<?> authentication) {
return JwtAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtAuthenticationToken jwtAuthentication = (JwtAuthenticationToken) authentication;
return processUserAuthentication(
String.valueOf(jwtAuthentication.getPrincipal()),
jwtAuthentication.getCredentials()
);
}
private Authentication processUserAuthentication(String principal, String credentials) {
try {
//JwtAuthenticationToken에 들어있는 JwtAuthentication이 토큰과 유저이름이 유효한지 확인
User user = userService.login(principal, credentials);
List<GrantedAuthority> authorities = user.getGroup().getAuthorities();
String token = getToken(user.getLoginId(), authorities);
JwtAuthenticationToken authenticated =
new JwtAuthenticationToken(new JwtAuthentication(token, user.getLoginId()), null, authorities);
authenticated.setDetails(user);
return authenticated;
} catch (IllegalArgumentException e) {
throw new BadCredentialsException(e.getMessage());
} catch (DataAccessException e) {
throw new AuthenticationServiceException(e.getMessage(), e);
}
}
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public User login(String principal, String credentials) {
checkArgument(isNotEmpty(principal), "principal must be provided.");
checkArgument(isNotEmpty(credentials), "credentials must be provided.");
User user = userRepository.findByLoginId(principal)
.orElseThrow(() -> new UsernameNotFoundException("Could not found user for " + principal));
user.checkPassword(passwordEncoder, credentials);
return user;
}
@Transactional(readOnly = true)
public Optional<User> findByLoginId(String loginId) {
checkArgument(isNotEmpty(loginId), "loginId must be provided.");
return userRepository.findByLoginId(loginId);
}
}
인증을 처리하는 기능을 추가하기 위해 AuthenticationManager에 앞서 만든 JwtAuthenticationProvider를 추가한다.
@Configuration
public class SecurityConfiguration {
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
Jwt jwt = getApplicationContext().getBean(Jwt.class);
return new JwtAuthenticationFilter(jwtConfigure.getHeader(), jwt);
}
@Bean
public JwtAuthenticationProvider jwtAuthenticationProvider() {
Jwt jwt = getApplicationContext().getBean(Jwt.class);
UserService userService = getApplicationContext().getBean(UserService.class);
return new JwtAuthenticationProvider(jwt, UserService);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authenticationProvider(jwtAuthenticationProvider())
.authorizeRequests()
.antMatchers("/me").hasAnyRole("USER","ADMIN")
.anyRequest().permitAll()
.and()
.addFilterAfter(jwtAuthenticationFilter(), SecurityContextPersistenceFilter.class);
return http.build();
}
}
JwtAuthenticationFilter에서 principal 필드에 JwtAuthentication 객체를 저장, details 필드에는 WebAuthenticationDetails 객체를 저장하도록 수정한다.
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
String token = getToken(request);
if (token != null) {
try {
//토큰 유효성 확인하기
Jwt.Claims claims = verify(token);
log.debug("Jwt parse result: {}", claims);
//토큰에 저장된 사용자 이름과 권한 가져오기
String username = claims.username;
List<GrantedAuthority> authorities = getAuthorities(claims);
if (isNotEmpty(username) && authorities.size() > 0) {
//사용자 이름과 권한을 바탕으로 인증 객체 생성
JwtAuthenticationToken authentication =
new JwtAuthenticationToken(new JwtAuthentication(token, username), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.warn("Jwt processing failed: {}", e.getMessage());
}
}
} else {
log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
SecurityContextHolder.getContext().getAuthentication());
}
chain.doFilter(request, response);
}
Jwt 인증 객체를 활용한 정보 조회 API와 로그인 API를 구현한다.
@RestController
@RequestMapping("/api")
public class UserRestController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
public UserRestController(UserService userService, AuthenticationManager authenticationManager) {
this.userService = userService;
this.authenticationManager = authenticationManager;
}
@GetMapping(path = "/user/me")
public UserDto me(@AuthenticationPrincipal JwtAuthentication authentication) {
return userService.findByLoginId(authentication.username)
.map(user ->
new UserDto(authentication.token, authentication.username, user.getGroup().getName())
)
.orElseThrow(() -> new IllegalArgumentException("Could not found user for " + authentication.username));
}
@PostMapping(path = "/user/login")
public UserDto login(@RequestBody LoginRequest request) {
JwtAuthenticationToken authToken = new JwtAuthenticationToken(request.getPrincipal(), request.getCredentials());
Authentication resultToken = authenticationManager.authenticate(authToken);
JwtAuthentication authentication = (JwtAuthentication) resultToken.getPrincipal();
User user = (User) resultToken.getDetails();
return new UserDto(authentication.token, authentication.username, user.getGroup().getName());
}
}
인증된 Authentication 구현체에 principal 필드 값을 가져오는 아노테이션으로 AuthenticationPrincipalArgumentResolver를 통해 Authentication 구현체 내부 principal 필드 타입으로 변환된 객체를 반환해준다.
1. 사용자로부터 로그인에 필요한 아이디와 비밀번호 정보가 들어온다.
2. 사용자가 입력한 정보를 바탕으로 JwtAuthenticationToken을 만든다.
3. AuthenticationManager에 등록된 JwtAuthenticationProvider가 authenticate 메소드를 사용하여 생성된 JwtAuthenticationToken에 대한 인증을 한다.
4. principal에 있는 아이디와 credential에 있는 비밀번호가 DB에 저장된 정보와 일치하는지 확인한다.
5. 일치하는 경우 principal에 인증된 JwtAuthenticationToken을 생성해서 반환한다.