WebSecurityConfig을 통해 설정하고 인증, 인가 필터를 적용하여 인증, 인가 기능을 마무리했다.
WebSecurityConfig
@Configuration
@EnableWebSecurity
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("/api/members/auth/signup").permitAll() // 회원가입
.requestMatchers( "/api/members/auth/login").permitAll() // 로그인 요청 제외
.anyRequest().authenticated() // 그 외 모든 요청에 대해 인증처리
);
// 필터
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
return http.build();
}
}
JwtAuthenticationFilter 인증 처리
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/members/auth/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getEmail(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
logger.error("Error parsing LoginRequestDto in attemptAuthentication", e);
return null;
} catch (AuthenticationException e) {
logger.error("Authentication error in attemptAuthentication", e);
throw e;
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
String email = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getEmail(); // username == email
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(email, role);
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
logger.info("로그인 실패");
response.setStatus(401);
}
}
JwtAuthorizationFilter 인가 처리
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.getTokenFromCookies(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 email) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(email);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String email) {
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserService userService;
public UserDetailsServiceImpl(UserService userService) {
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
P_user user = userService.findByEmail(email);
if (user == null) {
throw new UsernameNotFoundException("User not found by email: " + email);
}
return new UserDetailsImpl(user);
}
}
UserDetailsImpl
public class UserDetailsImpl implements UserDetails {
private final P_user user;
public UserDetailsImpl(P_user user) {
this.user = user;
}
public P_user getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@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;
}
}
이렇게 하고 UserController와 UserService에서 로그인 메서드를 삭제했다.
Spring Security에서 해주니까. 👍
그리고 유저 정보를 가져올 때마다 토큰을 검증하고 객체를 조회해서 반환하는 메서드를 사용했었는데 토큰 검증 과정도 필요없어서 날렸다.
이후 유저 객체는 이렇게 조회할 수 있었다.
// UserService
public ResponseDto getMypage(UserDetailsImpl userDetails) {
P_user user = userDetails.getUser();
...
}
구현 과정에서 문제가 있었는데,
WebSecurityConfig에서 requestMatchers().permitAll() 설정을 해두어도 로그인, 회원가입 시 필터에 걸려 에러가 발생해 Forbidden이 반환되었다.
permitAll() 처리를 하면 필터를 거치지 않고 바로 진입하는 줄 알았는데 그게 아니었다.
requestMatchers().permitAll()로 URL을 설정하면, 해당 요청에 대해 인증이 필요하지 않다는 것을 의미하고, 이것이 필터 체인을 완전히 건너뛴다는 뜻은 아니다.
여전히 Spring Security의 필터 체인을 거치지만 필터 체인 내에서 인증 및 권한 확인을 수행하지 않고 요청을 컨트롤러로 전달하는 것이다.
그리고 돌이켜보니 사실 필터에 걸려 에러가 난 것도 아니었다. 내가 조건 분기처리를 잘못했던 것 같다.
그렇게 몇 시간을 보내어 로그인은 가능케 했으나, 여전히 나의 JWT 토큰은 Bearer 접두사와 함께 쿠키에 담겨 주고받는 상태였다.
이왕 수정할거 쿠키를 어떻게 보내는 게 좋을지 부터 고민되었다.
보안 요구사항, 사용 시나리오, 애플리케이션 구조에 따라 달라진다.
장점
장점
현재 진행하는 프로젝트의 요구사항과 비즈니스 로직을 보면 (아마도) 모바일 앱일 확률이 높았다. 그리고 이왕 헤더에 저장하는 김에 리프레시 토큰과 엑세스 토큰을 함께 적용해보기로 했다.
JwtAuthenticationFilter
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/members/auth/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getEmail(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
logger.error("Error parsing LoginRequestDto in attemptAuthentication", e);
return null;
} catch (AuthenticationException e) {
logger.error("Authentication error in attemptAuthentication", e);
throw e;
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
String email = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getEmail();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String accessToken = jwtUtil.createAccessToken(email, role.name());
String refreshToken = jwtUtil.createRefreshToken(email);
jwtUtil.addAccessTokenToHeader(accessToken, response); // 액세스 토큰을 헤더에 추가
jwtUtil.addRefreshTokenToCookie(refreshToken, response); // 리프레시 토큰을 쿠키에 추가
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
logger.info("로그인 실패");
response.setStatus(401);
}
}
JwtAuthorizationFilter
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 request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = jwtUtil.getTokenFromHeader(request, "Authorization");
if (StringUtils.hasText(accessToken)) {
try {
// 액세스 토큰이 유효하면 인증 설정
if (jwtUtil.validateToken(accessToken)) {
setAuthentication(jwtUtil.getUserInfoFromToken(accessToken).getSubject());
}
} catch (ExpiredJwtException e) {
// 액세스 토큰이 만료된 경우 리프레시 토큰 사용
String refreshToken = jwtUtil.getTokenFromCookies(request, JwtUtil.REFRESH_TOKEN_COOKIE);
if (StringUtils.hasText(refreshToken) && jwtUtil.validateToken(refreshToken)) {
String email = jwtUtil.getUserInfoFromToken(refreshToken).getSubject();
String role = userDetailsService.findRoleByEmail(email);
// 새로운 액세스 토큰 발급 및 헤더 추가
String newAccessToken = jwtUtil.createAccessToken(email, role);
jwtUtil.addJwtToHeader(newAccessToken, response, "Authorization");
// 새 액세스 토큰으로 인증 설정
setAuthentication(email);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
}
filterChain.doFilter(request, response);
}
// 인증 처리
public void setAuthentication(String email) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(email);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String email) {
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
JwtUtil
@Component
@PropertySource("classpath:application.yml")
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
public static final String BEARER_PREFIX = "Bearer ";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
// 토큰 만료시간
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
private static final long ACCESS_TOKEN_EXPIRATION = 30 * 60 * 1000L; // 30분
private static final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000L; // 7일
@Value("${spring.jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private static final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String email, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(email) // 사용자 식별자값(email)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// 액세스 토큰 생성
public String createAccessToken(String email, String role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION);
return Jwts.builder()
.setSubject(email) // 사용자 식별자
.claim("role", role) // 권한 정보 추가
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 리프레시 토큰 생성
public String createRefreshToken(String email) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION);
return Jwts.builder()
.setSubject(email) // 사용자 식별자만 포함
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 액세스 토큰을 헤더에 추가
public void addAccessTokenToHeader(String accessToken, HttpServletResponse response) {
response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken);
}
// 리프레시 토큰을 HttpOnly 쿠키에 추가
public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse response) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE, refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true); // HTTPS에서만 전송되도록 설정
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60); // 예: 1주일
response.addCookie(cookie);
}
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
// `Bearer `의 공백을 `%20`로 인코딩
String encodedToken = URLEncoder.encode(token, "utf-8").replace("+", "%20");
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, encodedToken);
cookie.setPath("/");
cookie.setHttpOnly(true); // HttpOnly 설정
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty");
}
return false;
}
public String getTokenFromHeader(HttpServletRequest request, String headerName) {
// 요청에서 헤더 값 가져오기
String headerValue = request.getHeader(headerName);
// 헤더 값이 존재하고, "Bearer "로 시작할 경우
if (StringUtils.hasText(headerValue) && headerValue.startsWith(BEARER_PREFIX)) {
// "Bearer "를 제거하고 순수 토큰만 반환
return headerValue.substring(BEARER_PREFIX.length());
}
// 토큰이 없거나 형식이 맞지 않을 경우 null 반환
return null;
}
// 토큰을 헤더에 추가
public void addJwtToHeader(String token, HttpServletResponse response, String headerName) {
// 헤더에 "Bearer " 접두사와 함께 토큰을 추가
response.setHeader(headerName, BEARER_PREFIX + token);
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// 쿠키에서 지정된 이름의 토큰 추출
public String getTokenFromCookies(HttpServletRequest req, String cookieName) {
if (req.getCookies() != null) {
for (Cookie cookie : req.getCookies()) {
if (cookieName.equals(cookie.getName())) {
return cookie.getValue().replace("%20", " "); // 토큰 값 반환
}
}
}
logger.error("쿠키에서 " + cookieName + " 토큰을 찾을 수 없음");
return null;
}
}
TokenController
@RestController
@RequestMapping("/api/token")
public class TokenController {
private final JwtUtil jwtUtil;
private final UserService userService;
public TokenController(JwtUtil jwtUtil, UserService userService) {
this.jwtUtil = jwtUtil;
this.userService = userService;
}
@PostMapping("/refresh")
public ResponseEntity<?> refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = jwtUtil.getTokenFromHeader(request, "Refresh-Token");
String email = jwtUtil.getUserInfoFromToken(refreshToken).getSubject();
UserRoleEnum role = userService.findRoleByEmail(email);
if (refreshToken != null && jwtUtil.validateToken(refreshToken)) {
String newAccessToken = jwtUtil.createAccessToken(email, role.name());
jwtUtil.addJwtToHeader(newAccessToken, response, "Authorization");
return ResponseEntity.ok("New access token issued");
} else {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
}
}
}
유저 조회 방법
// 현재 로그인한 유저 객체 반환
public P_user getUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null && authentication.getPrincipal() instanceof UserDetailsImpl userDetailsImpl) {
String email = userDetailsImpl.getUser().getEmail();
P_user user = findByEmail(email);
if (user == null) {
throw new CustomApiException("해당하는 사용자 없음");
}
return user;
}
throw new CustomApiException("인증된 사용자 아님");
}