
이전글에서 이어집니다
https://hwlee9905.tistory.com/22
Spring Security를 활용하여 JWT 로그인 구현하기 - 1
Spring Security의 개념 스프링 시큐리티는 스프링 기반의 어플리케이션의 보안(인증과 권한)을 담당하는 프레임워크를 말하는데, 보안과 관련해서 체계적으로 많은 옵션들을 지원해준다. 스프링
hwlee9905.tistory.com
Spring Security 인증 순서
로그인이 꽤나 복잡해보이지만 결국 기존 Spring Security의 로직을 알고 있다면 이해하기 쉬울 것이다.
- 로그인시 클라이언트가 보낸 id와 pw를 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 인증을 시작한다
- AuthenticationManager는 UserDetailsService가 DB에서 조회한 데이터를 UserDetails를 통해 받아오고 로그인에 성공하면 successfulAuthentication를, 실패하면 unsuccessfulAuthentication 메소드를 호출한다.
- 또한 AuthenticationManager는 로그인에 성공하면 successfulAuthentication 메소드를 호출함과 동시에 인증정보를 SecurityContext에 Authentication 객체로 등록한다.
- 우리는 추가적으로 successfulAuthentication 메소드에서 JWT를 Client에게 발급해주는 일만 하면 된다.
- 그림에선 생략되어 있지만, 1편에서 언급했다시피 AuthenticationManager의 구현체는 ProviderManager이다. 사실상 인증에서 제일 중요한 역할을 하는 구현체이다.
여기서 의아한게 있을텐데, /login 경로에 해당하는 Controller가 없다는 것이다.
바로 UsernamePasswordAuthenticationFilter가 컨트롤러 역할을 수행한다.
UsernamePasswordAuthenticationFilter 내부에는 다음과 같이 기본 경로로 /login이 설정되어 있으므로
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
우리는 UsernamePasswordAuthenticationFilter를 상속받은 클래스를 구현하면 해당 클래스가 로그인 컨트롤러의 역할을 해줄 것이다.
복잡해보이지만, 이게 전부다! 코드로 따라가보자.
로그인 필터 구현
@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//클라이언트 요청에서 id, password 추출
String id = obtainUsername(request);
String password = obtainPassword(request);
log.info("login password : " + password);
//스프링 시큐리티에서 id와 password를 검증하기 위해서 token에 담는다.
// (token이 AuthenticationManager로 넘겨질 때 dto 역할을 한다.)
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(id, password, null);
return authenticationManager.authenticate(authenticationToken);
}
//로그인 성공시 실행하는 메소드 (이곳에서 JWT를 발급합니다.)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
//UserDetailsS
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String userId = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
log.info("로그인 성공 : userRole = " + role);
log.info("로그인 성공 : userId = " + userId);
String token = jwtUtil.createJwt(userId, role, 60*60*60L);
log.info("로그인 성공 : 해당 토큰을 로그인시 Header.Authorization에 포함하세요. Bearer " + token);
response.addHeader("Authorization", "Bearer " + token);
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
LocalDateTime currentTime = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedTime = currentTime.format(formatter);
Map<String, Object> responseData = new HashMap<>();
responseData.put("timestamp", formattedTime);
responseData.put("message", "로그인 성공");
responseData.put("로그인 성공. 해당 토큰을 api 요청시 Header.Authorization에 포함하세요.", "Bearer " + token);
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(responseData);
response.getWriter().write(json);
}
//로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.error("로그인 실패");
//로그인 실패 응답
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
LocalDateTime currentTime = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedTime = currentTime.format(formatter);
Map<String, Object> responseData = new HashMap<>();
responseData.put("timestamp", formattedTime);
responseData.put("error", "Authentication failed: " + failed.getMessage() +" ID와 PW를 확인해주세요.");
responseData.put("errorCode", HttpStatus.UNAUTHORIZED.value());
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(responseData);
response.getWriter().write(json);
}
@Override
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter("id");
}
}
UsernamePasswordAuthenticationFilter 내부에서는 user id를 가져오는 역할을 하는 obtainUsername이라는 메소드가 존재하는데 SpringSecurity 대부분의 메소드에서는 user id에 해당하는 key를 "username"으로 정의하고 있다
따라서, 우리는 obtainUsername을 오버라이딩하여 "id"에 해당하는 key를 가져오도록 했다 물론, dto에서 id를 username으로 변환해도 무방하다.
로그인에 성공했을시 JWT발급은 쿠키에 포함하여 보내는 방법과 header에 포함하는 방법이 있는데, 필자는 header에 JWT를 포함하여 response 하도록 하였다.
따라서, 클라이언트는 이후 요청에서 header의 authorization value로 JWT를 포함하여 api를 요청해야 한다.
SpringConfig에서 user와 admin 권한 2개로 나누었으므로 권한에 맞는 api 요청인지 확인할것이다.
에러 처리하는 부분이 눈에 띌텐데, @ExceptionHandler와 @ControllerAdvice를 통해 공통으로 에러처리를 하는 것이 보통이지만, 이는 SpringMVC의 기능이므로 dispatcherServlet전에 위치하고 있는 filter에서는 쓸 수 없다.
그렇다면 filter에서의 에러처리는 2가지 방법이 있는데,
- 그냥 바로 response에 에러 객체를 담아보낸다.
- Spring mvc로 에러를 보내고 ControlelrAdvice에서 처리하도록 한다.
여기서는 간단하게 1번을 사용하였다.
클라이언트가 로그인 시도를 하면 attemptAuthentication() 메소드에서 UsernamePasswordAuthenticationToken 객체에 클라이언트의 id와 pw를 담아
AuthenticationManager에게 전달할 것이다.
UserDetailsService 구현
DB에서 회원정보를 가져와 UserDetails에 담아 보내기 위해 UserService에서 UserDetailsService를 구현하였다.
loadUserByUsername를 override하여 구현해주면 된다.
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
private final AuthenticationRepository authenticationRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
//회원가입 로직
...
//로그인
@Transactional(readOnly = true)
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
//DB에서 조회
User user = userRepository.findByUserId(userId);
if (user != null) {
AuthTokenDto authTokenDto = AuthTokenDto.builder()
.username(user.getAuthentication().getUserId())
.password(user.getAuthentication().getPassword())
.role(user.getRole().toString())
.build();
//UserDetails에 담아서 return하면 AutneticationManager가 검증 함
return new CustomUserDetails(authTokenDto);
}
return null;
}
}
UserDetails 구현
AuthenticationManager가 검증하는 정보가 담겨 있는 UserDetials를 구현하는 코드이다.
해당 정보가 클라이언트가 입력한 id와 pw와 일치한다면 successfulAuthentication() 메소드가 실행된다.
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetails implements UserDetails {
private final AuthTokenDto authTokenDto;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
//권한 정보 반환
return authTokenDto.getRole();
}
});
return collection;
}
@Override
public String getPassword() {
return authTokenDto.getPassword();
}
@Override
public String getUsername() {
return authTokenDto.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
JWTUtil 구현
로그인에 성공했을때 successfulAuthentication() 메소드에서 JWT를 발급하기 위한 JWTUtil 클래스이다.
String token = jwtUtil.createJwt(userId, role, 60*60*60L);
바로 이 부분이다!
AuthenticationManager가 UserDetails에서 조회한 userId와 role을 JWTUtill에 넘겨주고 JWTUtil에서 JWT 토큰을 만드는 것이다.
여기까지 오면 모든게 연결되고 있는 느낌이 들 것이다..!
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secretKey}")String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String userId, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", userId)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
JWT 검증 필터
스프링 시큐리티 filter chain에 요청에 담긴 JWT를 검증하기 위한 커스텀 필터를 등록해야 한다.
해당 필터를 통해 요청 헤더 Authorization 키에 JWT가 존재하는 경우 JWT를 검증하고 강제로SecurityContextHolder에 세션을 생성한다. (이 세션은 STATLESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸 된다.)
@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException{
log.info("request.getRequestURI() in JWTFilter : "+request.getRequestURI());
//request에서 Authorization 헤더를 찾음
String authorization = request.getHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
log.info("authorization now");
//Bearer 부분 제거 후 순수 토큰만 획득
String token = authorization.split(" ")[1];
//토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {
log.info("token expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰에서 username과 role 획득
String userId = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
log.info("role = " + role);
log.info("userId = " + userId);
//userEntity를 생성하여 값 set
AuthTokenDto user = AuthTokenDto.builder()
.username(userId)
.role(role)
.password("temppassword")
.build();
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(user);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
Spring Security Config
이제 다 끝났다 ! Spring Security 설정에 우리가 만든 필터들을 추가해주고,
기타 csrf, cors, session들을 설정해주면 된다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
//AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
//비밀번호 암호화 메소드
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//csrf disable
http
.csrf(AbstractHttpConfigurer::disable);
//Form 로그인 방식 disable
http
.formLogin(AbstractHttpConfigurer::disable);
//http basic 인증 방식 disable
http
.httpBasic(AbstractHttpConfigurer::disable);
//경로별 인가 작업
http
.authorizeHttpRequests(auth -> auth
//관리자 기능 api 권한
.requestMatchers("/admin/**").hasAnyAuthority("ADMIN")
//유저 기능 api 권한(수정, 등록, 삭제)
.requestMatchers("/user/**").hasAnyAuthority("USER")
//비로그인 회원은 조회만 가능하도록 설정
.anyRequest().permitAll()
);
//session 설정
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
//JWTFilter 등록
http
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
//LoginFilter 등록
http
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
//cors 설정
http
.cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
//프론트와 협의하여 포트번호를 수정할 것
configuration.setAllowedOrigins(Collections.singletonList("https://localhost:8080"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
})));
return http.build();
}
}
로그인 테스트
POSTMAN을 사용하였다.
간단하게 회원가입부터 해준다. id는 user12, pw는 qwe123!@로 해준다.
우리가 successfulAuthentication() 메소드에서 구현하였던 response가 제대로 오는 모습이다.
로그인 후 경로 권한 테스트
회원 가입시 기본적으로 user 권한을 갖도록 하였기 때문에 /user 경로엔 접근이 잘 되는 모습이고 admin 경로엔 접근이 되지 않는 모습이다.
출처
https://www.youtube.com/watch?v=NPRh2v7PTZg&list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ&index=1&t=357s
'Spring' 카테고리의 다른 글
API마다 다르게 예외처리하는 방법 (0) | 2024.03.09 |
---|---|
Spring Security를 활용하여 JWT 발급, 자체 로그인, OAuth2 구현하기 - 3 (0) | 2024.03.07 |
Spring Security를 활용하여 JWT 발급, 자체 로그인, OAuth2 구현하기 - 1 (2) | 2024.03.05 |
공통 관심사를 처리하기 위한 Filter와 Interceptor (0) | 2024.03.02 |
쿠키와 세션을 활용하여 로그인 처리하기 (1) | 2024.02.19 |

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!