24.09.03 TIL - Spring Security Login with JWT
오늘 배운 것
- Spring Security 설정
- Spring Security Config 설정
- UserDetailsService 인터페이스 구현
- UserDetails 인터페이스 구현
- JWT 인증 처리 필터 생성
- JWT 인가 처리 필터 생성
- @AuthenticationPrincipal
- @Secured
개요
Spring Security의 기본 설정으로는 세션 기반 로그인이다.
로그인이 성공하면 세션 ID를 반환하는 방식으로 진행이 된다.
따라서 JWT 기반으로 로그인을 진행하려면 커스텀을 좀 해야 한다.
JWT를 기반으로 로그인을 하려면 어떻게 해야 하는지 보자.
Spring Security 설정
Spring Security를 사용하려면 역시 build.gradle에 추가해줘야 한다.
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
UserDetails 인터페이스 구현
UserDetails와 UserDetailsService 구현체를 커스텀해서 구현해야 한다.
커스텀해서 구현하게 되면 Spring Security의 default login 기능을 사용하지 않게 된다.
UserDetailsService, UserDetails를 커스텀해서 우리의 DB와 연결해서 User 정보 가져오게 하는 것이다.
이것 통해 AuthenticationManager가 우리 DB에 저장된 username, password로 인증, 인가 처리할 수 있게 구현한 것이다.
import com.sparta.springauth.entity.User;
import com.sparta.springauth.entity.UserRoleEnum;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
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는 인터페이스로 구현체를 커스텀해서 만드는 것이다.
아래의 글에서도 봤듯이 UserDetails는 우리 DB에서 찾은 User를 가지고 있어야 한다.
그리고 중요한 오버라이드 해야 하는 메서드로 getUsername(), getPassword(), getAuthorities()가 있다.
AuthenticationManager가 이 UserDetails에 있는 username과 password와 로그인 시도에서 넘어온 username, password를 비교해 인증 처리 하는 것이다.
2024.09.07 - [TIL] - 24.09.02 TIL - Spring Security 동작 원리
24.09.02 TIL - Spring Security 동작 원리
오늘 배운 것spring security 란?filter 란?spring security filter chainUsernamePasswordAuthenticationFilterSecurityContextHolderAuthenticationSpring Security 로그인 처리 과정 Spring Security 란?Spring Security는 프레임워크다.Spring Sec
dev-gongmyoung.tistory.com
getAuthorities() 메서드는 User에게 주어진 권한을 반환하는 메서드다.
이 메서드는 추후에 Authentication을 생성할 때 필요한 Authorities를 넣을 때 사용된다.
아마 아래와 같은 코드로 내부에서 진행이 되는 것 같다.
Authentication authentication
= new UsernamePasswordAuthenticationToken(userDetailsImpl, null, userDetails.getAuthorities());
권한은 SimpleGrantedAuthority 객체로 만들면 된다.
인가에는 권한이 반드시 필요하기 때문에 User 클래스에 권한 관련 필드는 반드시 있어야 하는 것 같다.
또 JWT에 username과 권한을 자주 넣는 이유가 있는 것 같다.
UserDetailsService 인터페이스 구현
UserDetails 커스텀해서 구현했으니 이제 이 UserDetails를 만들어주는 UserDetailsService도 커스텀해서 구현해야 한다.
import com.sparta.springauth.entity.User;
import com.sparta.springauth.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
UserDetailsService는 인터페이스로 구현체를 커스텀해서 만드는 것이다.
UserDetailsService에는 오버라이드 해야 하는 메서드가 하나 있는데 바로 loadUserByUsername(String username) 이다.
이 메서드는 username을 받아 우리 DB에서 User를 찾아와야 한다.
그리고 그 User를 가지고 우리가 커스텀한 UserDetails에 넣어서 반환하는 것이다.
이렇게 되면 UserDetailsServiceImpl은 우리 DB에서 User를 찾아오고 그것을 UserDetailsImpl에 넣어 반환하는 것이다.
그러면 이제 우리 DB에 있는 username, password를 가지고 인증을 처리할 수 있는 것이다.
JWT 인증 처리 필터 생성
Spring Security에서 인증 및 인가를 처리하기 위해 Filter를 사용한다고 했다.
인증을 처리하는 필터를 커스텀 해보자.
- 아래 글에서 Form Login 진행하면 Spring Security Filter 중 UsernamePasswordAuthenticationFilter 사용한다고 했다.
- UsernamePasswordAuthenticationFilter 안의 기능들 사용하기 위해 상속 받아 커스텀하는 것이다.
- UsernamePasswordAuthenticationFilter가 하는 역할을 보자.
- 사용자가 username, password 보내면 UsernamePasswordAuthenticationToken 만든다.
- 그 다음에 AuthenticationManager를 통해서 확인까지 하는 것이다.
위의 UsernamePasswordAuthenticationFilter가 하는 일을 직접할 것이다.
왜냐하면 로그인 성공하면 JWT까지 생성하고 반환해야 하는데 그대로 사용하면 기본 Spring Security 설정은 세션 방식 사용해 로그인 성공 시 세션 ID를 반환하기 때문이다.
따라서 인증을 처리하는 Spring Security Filter도 커스텀해서 사용해줘야 하는 것이다.
2024.09.07 - [TIL] - 24.09.02 TIL - Spring Security 동작 원리
24.09.02 TIL - Spring Security 동작 원리
오늘 배운 것spring security 란?filter 란?spring security filter chainUsernamePasswordAuthenticationFilterSecurityContextHolderAuthenticationSpring Security 로그인 처리 과정 Spring Security 란?Spring Security는 프레임워크다.Spring Sec
dev-gongmyoung.tistory.com
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.security.UserDetailsImpl;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper()
.readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult)
throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed)
throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
커스텀한 필터에서는 3개의 오버라이드한 메서드가 있다.
각각 인증 시도하는 메서드, 로그인 성공시 수행하는 메서드, 로그인 실패하는 메서드 이렇게 3개이다.
먼저 생성자부터 보자.
- 이제 로그인 성공 시 JWT를 반환해야 하기 때문에 이전에 만들어 놓은 JwtUtil을 주입 받는 것이다.
- 그리고 setFilterProcessesUrl 메서드를 생성자에서 실행하고 있다.
- 이 메서드는 이 필터가 동작하기 원하는 url을 설정하는 것이다.
- 그래서 /api/user/login url로 요청이 오면 이 필터가 동작을 하면서 인증 과정을 거치는 것이다.
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
2024.08.30 - [TIL] - 24.08.28 - JWT in Spring
24.08.28 - JWT in Spring
오늘 배운 것JWT in Spring 개요JWT를 사용해 Spring에서 로그인을 구현하려고 한다. 여기에서 사용하는 것들이 이후에 스프링 시큐리티를 사용해 로그인을 구현할 때에도 사용이 된다. 회원 가
dev-gongmyoung.tistory.com
그리고 이제 로그인 시도하는 메서드 attemptAuthentication을 보자.
- request의 body에 있는 JSON을 먼저 username, password를 필드로 가지는 LoginRequestDto로 변환하는 것이다.
- 그 후 getAuthenticationManager()로 AuthenticationManager를 가져온다.
- 그리고 AuthenticationManager의 authenticate() 메서드로 인증을 시도하는 것이다.
- 그리고 이전에 봤던 것처럼 인증을 성공하면 SecurityContextHolder에 Authentication 객체 넣는다.
- 인증 실패하면 SecurityContextHolder를 비우는 것이다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper()
.readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
2024.09.07 - [TIL] - 24.09.02 TIL - Spring Security 동작 원리
24.09.02 TIL - Spring Security 동작 원리
오늘 배운 것spring security 란?filter 란?spring security filter chainUsernamePasswordAuthenticationFilterSecurityContextHolderAuthenticationSpring Security 로그인 처리 과정 Spring Security 란?Spring Security는 프레임워크다.Spring Sec
dev-gongmyoung.tistory.com
그 다음 메서드는 로그인 성공 시 실행되는 successfulAuthentication 메서드를 보자.
- 이 메서드에서는 Authentication 객체를 파라미터로 받을 수 있다.
- Authentication 객체의 getPrincipal() 메서드로 principal 즉 UserDetails를 받아온다.
- UserDetails 커스텀한 UserDetailsImpl을 받아서 username과 role을 받아오는 것이다.
- 그 후 JWT를 만든 후 response의 Set-Cookie Header에 JWT를 넣는 것이다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult)
throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
}
마지막으로 로그인 실패 시 실행되는 unseccuessfulAuthentication 메서드를 보자.
- 여기에서는 그냥 response의 상태 코드를 401로 한 것이다.
- 로그인 실패 시 처리하고 싶은 로직을 작성하면 된다.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
JWT 인가 처리 필터 생성
이제 인가를 처리하는 필터를 커스텀 해보자.
이 필터에서는 들어온 JWT를 검증하고 인가하는 일을 하는 것이다.
import com.sparta.springauth.security.UserDetailsServiceImpl;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
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();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
인가 필터에서 해야 하는 일을 doFilterInternal 메서드에 작성하는 것이다.
doFilterInternal 메서드에서 하는 일을 보자.
- 먼저 request에서 JWT 값을 가져오는 것이다.
- JWT 값이 있다면 JWT를 substring하고 검증하는 것이다.
- 검증에 성공했다면 JwtUtil로 JWT에 저장된 Claims를 가져온다.
- 그 후 Claims에서 가져온 username을 setAuthentication() 메서드로 넘겨준다.
- 그리고 doFilter 메서드로 다음 필터로 넘어간다.
- JWT 값이 없다면 바로 doFilter 메서드로 다음 필터로 넘어간다.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
그러면 이제 setAuthentication 메서드를 보자.
- 먼저 빈 SecurityContext를 하나 SecurityContextHolder로부터 얻어온다.
- Authentication 객체를 createAuthentication 메서드에 username을 넘겨 생성한다.
- SecurityContext에 Authentication 객체를 설정한다.
- SecurityContextHolder에 SecurityContext를 설정한다.
원래 AuthenticationManager가 Authentication 인증 객체 만들고 SecurityContextHolder에 넣어주고 내부적으로 다 해준다.
지금은 JWT 사용하기 때문에 직접 토큰 검증하고 인가 처리 해줘야 한다.
직접 Authentication 인증 객체 만들고 SecurityContextHolder에 넣는 것 해야 하는 것이다.
그래서 setAuthentication 메서드가 필요한 것이다.
중요한 것은 인증 처리 되었다는 의미가 SecurityContextHolder에 Authentication 객체가 들어가 있는 것이다.
그런 일을 여기서 하는 것이다.
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
이제 Authentication 객체 만드는 createAuthentication 메서드를 보자.
- UserDetailsService에서 loadByUsername 메서드로 UserDetails 얻어온다.
- 그 후 UserDetails를 가지고 UsernamePasswordAuthenticationToken = Authentication 객체 만들어 반환한다.
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
Spring Security config 설정
이렇게 필터를 만들면 끝이 아니다.
필터를 등록하고 필요한 Spring Security 설정들도 추가해줘야 한다.
이런 일을 WebSecurityConfig에서 한다.
import com.sparta.springauth.jwt.JwtAuthorizationFilter;
import com.sparta.springauth.jwt.JwtAuthenticationFilter;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@EnableMethodSecurity(securedEnabled = true) // @Secured 애너테이션 활성화
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService,
AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@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);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources()
.atCommonLocations()).permitAll()
// resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll()
// '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
먼저 필요한 빈들을 등록하기 위한 config 파일이니 @Configuration을 붙여야 한다.
그리고 Spring Security를 사용하기 위해 @EnableWebSecurity를 붙여야 한다.
그리고 권한 제어할 수 있도록 @EnableMethodSecurity(securedEnabled = true) 해줘야 한다.
필터들을 @Bean으로 등록을 해줘야 한다.
그리고 인증 필터에서 AuthenticationManager가 필요하기 때문에 AuthenticationConfiguration을 주입 받는다.
그리고 AuthenticationManager도 AuthenticationConfiguration로 생성해서 Bean으로 등록한다.
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@EnableMethodSecurity(securedEnabled = true) // @Secured 애너테이션 활성화
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService,
AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@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);
}
그리고 이제 SecurityFilterChain을 빈으로 등록하고 설정해줘야 한다.
중요한 설정들로는 CSRF 설정과 Spring Security의 기본 설정인 세션 방식을 사용하지 않게 하는 설정이 있다.
그리고 authorizeHttpRequests 부분에서 인증 처리가 필요 없는 url을 설정하는 것이다.
그리고 중요한 것이 내가 커스텀한 인증, 인가 필터를 넣는 것이다.
addFilterBefore 메서드로 필터를 넣어주는 것이다.
인가 필터를 인증 필터 앞에 넣고 있다.
인증 필터는 Spring Security의 UsernamePasswordAuthenticationFilter 앞에 넣고 있다.
순서 : 인가 필터 → 인증 필터 → UsernamePasswordAuthenticationFilter
인가 필터를 앞에다 둔 이유는 인가를 먼저 하고 인가가 제대로 되지 않았으면 로그인 진행하는 순서가 맞기 때문이다.
SecurityContextHolder에 Authentication 객체를 넣었다면 Spring Security는 이미 인증이 완료된 것으로 간주해서 이후 필터를 건너 뛴다.
그래서 우리의 필터인 JwtAuthenticationFilter에서 인증이 성공하면 UsernamePasswordAuthenticationFilter 실행되지 않는다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
// resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll()
// '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@AuthenticationPrincipal
Controller에서 @AuthenticationPrincipal을 사용하면 Authentication의 Principal에 저장된 UserDetails를 가져올 수 있다.
로그인 성공해 인증 처리 된 User라면 UserDetails가 Authentication에 담겨 있다.
따라서 이 annotation으로 로그인 성공한 User의 정보를 가져올 수 있다.
그래서 예를 들어 로그인 한 User의 주문 정보만 가져오고 싶다면 이 annotation 사용해서 처리하면 된다.
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
// Authentication 의 Principal 에 저장된 UserDetailsImpl 을 가져옵니다.
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
@Secured
Spring Security를 사용하면 권한 제어도 쉽게 할 수 있다.
권한 제어란 사용자의 권한에 따라 접근할 수 있는 페이지, API 제한하는 것이다.
Spring Security의 권한 이름에는 규칙이 있는데 'ROLE_'로 시작해야 한다는 것이다.
그래서 나의 코드에는 아래처럼 'ROLE_USER', 'ROLE_ADMIN' 이렇게 설정했다.
public enum UserRoleEnum {
USER(Authority.USER), // 사용자 권한
ADMIN(Authority.ADMIN); // 관리자 권한
private final String authority;
UserRoleEnum(String authority) {
this.authority = authority;
}
public String getAuthority() {
return this.authority;
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
권한 제어하기 위해서는 @Secured 메서드를 Controller에 달아주면 된다.
그리고 속성으로 권한들을 주면 해당 권한에 해당하는 사용자들만 요청을 받아 Controller의 메서드가 진행이 된다.
권한은 1개 이상 설정이 가능하다.
2개부터는 @Secured({권한1, 권한2...}) 이렇게 중괄호로 묶어서 사용하면 된다.
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자용
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
System.out.println("authority.getAuthority() = " + authority.getAuthority());
}
return "redirect:/";
}
어떻게 권한 확인을 하는가 하면 UserDetails 커스텀할 때 getAuthorities() 메서드가 이 권한을 확인하는 메서드였다.
이 메서드를 통해 User의 권한을 가져오는 것이다.
@Secured 붙은 메서드 호출 시 Spring Security는 현재 Authentication 객체의 Authorities와 @Secured의 권한 비교한다.
권한이 일치하면 메서드에 접근할 수 있고, 일치하지 않으면 접근이 거부된다.
정리
Spring Security는 아주 복잡하고 뭔가 많은 것 같다.
많은 것을 2개의 글로 설명하려고 하니 너무 길어지고 복잡해진 것 같다...
그래도 딱 기억해야 할 것이 있다면 다음과 같은 것 같다.
- Spring Security는 필터를 사용해 인증, 인가 로직과 비즈니스 로직 분리한다.
- Form Login으로 하면 UsernamePasswordAuthenticationFilter에서 처리한다.
- AuthenticationManager에서 Authentication 객체 받아 인증을 시도한다.
- 인증 성공하면 SecurityContextHolder에 Authentication 객체 설정한다.
- Spring Security의 기본 로그인 설정은 세션 방식이다.
- Form Login에서 Spring Security 기본 username-password 말고 내 DB 사용하려면
UserDetails, UserDetailsService 커스텀해서 구현해야 한다. - JWT 사용하려면 UserDetails, UserDetailsService, 인증 필터, 인가 필터 커스텀해서 구현해야 한다.
- 로그인한 사용자 알아내려면 @AuthenticatinoPrincipal 사용하면 된다.
- 권한 제어하려면 @Secured 사용하면 된다.