Spring Security + JWT 이해하며 구현하기
https://hehesim.tistory.com/36
스프링 시큐리티 인증 절차
1) Authentication (인증) : 'A'라고 주장하는 주체가 'A'가 맞는지 확인하는 것. - 코드에서 Authentication : 인증 과정에 사용되는 핵심 객체. 2) Authorization (권한) : 특정 자원에 대한 권한이 있는지 확인하
hehesim.tistory.com
이것은 내가 이전에 공부하며 만들었던 Spring Security 인증 절차이다.
이때는 딱 Spring Security만을 사용하여 인증과정을 구현하고 공부했다.
이번 프로젝트에서는 여기에 더하여 JWT토큰을 활용한 인증을 구현했다.
이유는 Stateless를 지키기 위하여였다. 상태를 저장하지 않기 때문에 세션관리가 필요없고, 인증 시 위변조 확인까지 가능하다.
1. JwtTokenFilter 추가
그래서 JwtTokenFilter를 활용하여 매 요청마다 JwtToken을 확인하는 과정을 만들었다.
실행 시 SecurityFilterChain을 살펴보면 이런 여러가지 필터들을 거쳐간다.
filters : DisableEncoderUrlFilter - WebAsyncManagerIntegrationFilter - SecurityContextHolderFilter - HeaderWriterFilter - CorsFilter - LogoutFilter - RequestCacheAwareFilter - SecurityContextHolderAwareRequestFilter - AnonymousAuthenticationFilter - SessionManagementFilter - ExceptionTranslationFilter - JwtTokenFilter - AuthorizationFilter
여기서 JwtTokenFilter을 내가 추가한 것이다.
- addFilterBefore(new JwtTokenFilter(jwtTokenUtils, manager), Authorization.class)
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenUtils jwtTokenUtils;
private final UserDetailsManager manager;
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity
) throws Exception {
httpSecurity
//csrf 보안 해제
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// AuthorizationFilter이전에 TokenFilter를 추가해줌.
.addFilterBefore(new JwtTokenFilter(jwtTokenUtils, manager), AuthorizationFilter.class)
//url에 따른 요청 인가
.authorizeHttpRequests(
auth -> //로그인, 회원가입은 익명사용자만 요청 가능
auth
.requestMatchers("/error", "/payment/**",
"/static/**", "/toss/**", "shopping-malls/order/{orderId}/payment").permitAll()
// 관리자 페이지는 관리자만 가능
.requestMatchers("/admin/**")
.hasAnyRole("ADMIN")
// 로그인과 회원가입은 익명 사용자만 가능
.requestMatchers("/users/login", "/users/register")
.anonymous()
// 추가정보 입력은 inactive만 가능
.requestMatchers("/users/{accountId}/additional-info")
.hasAnyRole("INACTIVE")
// 중고거래, 비즈니스 계정 신청/확인, 쇼핑몰 서비스 이용 active, business만 가능
.requestMatchers("/users/**", "/used-goods/**")
.hasAnyRole("ACTIVE", "BUSINESS")
// 쇼핑몰 운영 서비스는 business회원만 가능
.requestMatchers("/shopping-malls/**", "/payment/**")
.hasAnyRole("ACTIVE", "BUSINESS", "ADMIN")
.anyRequest().authenticated()
);
return httpSecurity.build();
}
}
2. JwtTokenFilter 내부
JwtTokenFilter를 자세하게 살펴보자.
- OncePerRequestFilter를 상속받아 각 요청마다 이 필터를 거쳐가게끔 한다.
- doFilterInternal()를 오버라이딩하여 여기서 JWT Token을 확인하고, 인증정보를 생성하고, 등록하는 과정을 실시한다.
- 1) Http요청 헤더에서 Authorization 헤더를 회수
- 2) 헤더가 존재하는지, Bearer 로 시작하는지 확인 => JwtToken
- 3) 토큰이 존재하면 유효한 토큰인지 확인
- 4) 유효하다면 토큰에서 사용자 정보를 가져온다 => UserDetails
- 5) 토큰과 토큰에서 가져온 사용자 정보를 활용하여 AuthenticationToken을 생성한다.
(Principal, Credentials, Collection<GrantedAuthority> = userDetails, token, userDetails.getAuthority())
- 6) 생성한 인증정보를 SecurityContext에 등록. (SecurityContextHolder로 SecurityContext를 만들고, 여기에 setAuthentication())
- 이후 꼭 filterChain을 연결해주어야 한다. (filterChain.doFilter(request, response);
// JwtTokenFilter class
//들어온 요청에 대해 토큰 유효성 확인 &이에 따른 인증정보를 등록해주는 필터
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final JwtTokenUtils jwtTokenUtils;
private final UserDetailsManager manager;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. authorization 헤더를 회수
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. 헤더가 존재하는지, Bearer로 시작하는지
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// 3. 존재하면 유효한 토큰인지 확인
String token = authHeader.split(" ")[1];
//토큰 확인
if (jwtTokenUtils.validate(token)) {
// contextHolder에서 context를 만들고,
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 토큰에서 사용자 정보 가져오기
String username = jwtTokenUtils.parseClaims(token).getSubject();
log.info("username:: "+username);
UserDetails userDetails = manager.loadUserByUsername(username);
log.info(userDetails.toString());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
log.info("authority: {}", authority.getAuthority());
}
//인증 정보 생성
AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, token, userDetails.getAuthorities()
);
//인증 정보 등록
context.setAuthentication(authenticationToken);
SecurityContextHolder.setContext(context);
log.info("===set security context with jwt");
log.info("Auth :: "+SecurityContextHolder.getContext().getAuthentication().toString());
} else {
log.warn("jwt validation failed!");
}
}
filterChain.doFilter(request, response);
}
}
3. JwtTokenUtils
** JwtTokenUtils : JwtToken 관련 서비스를 실행함.
- 생성시 : yaml 파일의 jwtSecret을 암호화하여 signingKey로 저장, 이 signingKey를 담은 jwtParser 저장.
- generateToken() : UserDetails를 받아서 JWT로 변환하는 과정. + 여기서 auth라는 Claim에다가 그 유저의 authority를 담는다. 즉, token을 통해서 그 유저의 권한을 알 수 있도록 하는 것.
- parseClaim() : JWT 토큰을 통해 payload를 반환 (Claims를 반환)
(토큰의 구조: header - payload (claim, sub, iat, exp...) - signature)
- isNotExpired(): JWT가 만료된 토큰인지 확인
- validate() : 해당 JWT 토큰이 정상적인 JWT인지 확인하는 과정. (isNotExpired & parseClaim이 정상적으로 되어야 한다.)
// JwtTokenUtils
@Slf4j
@Component
public class JwtTokenUtils { //JwtToken관련 서비스
private final Key signingKey;
private final JwtParser jwtParser;
public JwtTokenUtils(
@Value("${jwt.secret}")
String jwtSecret
) {
this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());
this.jwtParser = Jwts.parserBuilder().setSigningKey(signingKey).build();
}
// UserDetails를 받아서 JWT로 변환
// header - payload : claim, sub, iat, exp - signature
public String generateToken(UserDetails userDetails) {
Instant now = Instant.now(); //시간
Claims jwtClaims = Jwts
.claims()
.setSubject(userDetails.getUsername())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plusSeconds(86400L))); //todo: 임시적인 시간 (줄여야한다)
jwtClaims.put("auth", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
// jwt 발급
return Jwts.builder()
.setClaims(jwtClaims)
.signWith(this.signingKey)
.compact(); //압축
}
// payload를 반환
public Claims parseClaims(String token) {
return jwtParser.parseClaimsJws(token).getBody();
}
// JWT가 만료된 토큰인지 확인해야 함.
public boolean isNotExpired(String token) {
if (Instant.now().isAfter(this.parseClaims(token).getExpiration().toInstant())) {
return false;
}
return true;
}
// 정상적인 jwt인지 판단
public boolean validate(String token) {
try {
if (this.isNotExpired(token)) {
this.parseClaims(token);
}
return true;
} catch (Exception e) {
log.warn("invalid jwt");
}
return false;
}
}
4. PasswordEncoder
: 비밀번호 암호화 과정이 필요하므로 PasswordEncoder도 Configuration을 만들고, Bean으로 등록해주어야 한다. 암호화 방식은 BCrypt로 설정했다.
//비밀번호 인코더
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. CustomUserDetailsManager
그리고, 이러한 Jwt를 로그인시 발급받고,,,, 등등의 유저 인증 관련 처리들을 위한 UserDetailsManager의 구현체를 만들어주었다.
그 내부를 간단하게 정리해보자면 UserDetailsService를 UserDetailsManager가 상속받고, 얘를 구현한 구현체로 CustomUserDetailsManager를 만들어준 것. 그래서 여기 내부의 메서드들을 모두 오버라이딩한 클래스가 되었고, 여기에 필요에 따른 다른 인증 관련 메서드들도 구현해주었다.
- userLogin() : 로그인 시의 과정을 구현했다. 계정 id가 있는지 확인하고, 비밀번호가 일치하는지 확인. 둘다 일치한다면 토큰을 발급하고, 일단은 토큰 정보를 return하도록 만들었다.
- createUser() : 회원가입 시의 과정을 구현했다. 이때 UserDetails형태로 받아와서 실제 UserRepository의 엔티티인 UserEntity형태로 변환 및 암호화하는 과정을 통해 저장하였다.
- checkIdIsEqual() : 어떤 메서드를 실행할 때 로그인한 사용자가 해당 메서드의 권한이 있는지를 확인하기 위해 미리 메서드를 만들었다. 현재 인증정보의 아이디와 같은지 확인하는 과정이다.
- checkIdIsNotEqual(): 반대로 해당 아이디와 인증정보의 아이디가 같지 않은지 확인하는 과정이다.
- loadUserFromAuth(): 인증정보에서 UserEntity를 추출하는 메서드.

// CustomUserDetailsManager class
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsManager implements UserDetailsManager {
private final JwtTokenUtils jwtTokenUtils;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final AuthenticationFacade facade;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
//repo에서 userId로 찾아오기
Optional<UserEntity> optionalUser = userRepository.findByAccountId(userId);
if (optionalUser.isEmpty()) {
log.info("loadUserByUsername : not found");
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
UserEntity userEntity = optionalUser.get();
//리턴을 UserDetails 형태로 한다
UserDetails userDetails = CustomUserDetails.builder()
.userId(userEntity.getAccountId())
.password(userEntity.getPassword())
.authorities(userEntity.getAuthority())
.build();
return userDetails;
}
public JwtResponseDto userLogin(JwtRequestDto dto) {
// 넘어온 dto의 계정id가 없는 계정이라면 예외 발생
UserDetails userDetails = this.loadUserByUsername(dto.getUserId());
// 비밀번호가 옳지 않아도 예외 발생
if (!passwordEncoder.matches(dto.getPassword(), userDetails.getPassword())) {
log.warn("password mismatch");
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
//일치하면 토큰 발급
String generatedToken = jwtTokenUtils.generateToken(userDetails);
// 토큰과 subject dto에 넣어 반환
JwtResponseDto jwtResponseDto = JwtResponseDto.builder()
.token(generatedToken)
.subject(jwtTokenUtils.parseClaims(generatedToken).getSubject())
.build();
log.info("토큰 발급 완료 : {}", jwtResponseDto.getSubject());
return jwtResponseDto;
}
@Override
public void createUser(UserDetails user) {
// userId가 겹치면 안됨
if (this.userExists(user.getUsername())) {
log.info("user already exists");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
try {//안겹치면 가입진행
CustomUserDetails userDetails = (CustomUserDetails) user;
UserEntity newUser = UserEntity.builder()
.accountId(userDetails.getUserId())
.password(passwordEncoder.encode(userDetails.getPassword()))
.authority(userDetails.getStringAuthorities())
.build();
userRepository.save(newUser);
} catch (Exception e) {
log.error("Failed to create user: {}", e.getMessage());
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Override
public boolean userExists(String userId) {
return userRepository.existsByAccountId(userId);
}
// 로그인한 아이디와 해당 아이디가 같은지 확인하는 메서드
public void checkIdIsEqual(String accountId) {
String loginId = facade.getAuth().getName(); //현재 인증정보의 id name
// 다르면 예외처리
if (!accountId.equals(loginId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
// 같으면 계속 이어서 감
}
// 로그인한 아이디와 해당 아이디가 같은지 확인하는 메서드
public void checkIdIsNotEqual(String accountId) {
String loginId = facade.getAuth().getName(); //현재 인증정보의 id name
// 다르면 예외처리
if (accountId.equals(loginId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
// 같으면 계속 이어서 감
}
// 인증정보에서 UserEntity를 추출하는 메서드
public UserEntity loadUserFromAuth() {
//repo에서 userId로 찾아오기
Optional<UserEntity> optionalUser = userRepository.findByAccountId(facade.getAuth().getName());
if (optionalUser.isEmpty()) {
log.info("loadUserFromAuth : not found");
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return optionalUser.get();
}
@Override
public void updateUser(UserDetails user) {
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
}
@Override
public void deleteUser(String username) {
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
}
@Override
public void changePassword(String oldPassword, String newPassword) {
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
}
}
6. CustomUserDetails
: UserDetailsManager와 마찬가지로 UserDetails도 인터페이스이므로 구현체를 만들어주어야 한다.
이 때 이미 스프링에서 만들어둔 User라는 구현체가 있지만, 나는 확장성과 유연성을 위하여 CustomUserDetails를 만들어주었다. UserDetails의 모든 메서드들을 오버라이딩하였고, String형태로 Authority를 반환하는 메서드를 추가로 만들어주었다.
전체 과정을 이렇게 정리해보았다.
JwtTokenFilter이후에 Authorization.class로 가서 그 사용자의 권한에 따라 접근 가능한지/불가능한지를 확인하면 된다.
'Project > shoppingmall project' 카테고리의 다른 글
[리팩토링] 예외 처리와 계층분리/책임분리 (1) | 2025.01.04 |
---|---|
쇼핑몰 프로젝트 회고 (0) | 2024.03.05 |
쇼핑몰 프로젝트 고민거리들 및 오류 해결 과정 (0) | 2024.03.05 |
쇼핑몰 프로젝트 (0) | 2024.02.22 |