스프링 기반 애플리케이션에 강력한 인증(Authentication)과 인가(Authorization)기능을 제공하는 보안 프레임워크이다.
이러한 Security 보안을 통과하기 위해서는 SecurityContext의 AbstractAuthenticationToken을 set 해주어야한다.
💡 SecurityContext란?
SecurityContext는 보통
하나의 Authentication 객체를 포함하며, 이 객체는 사용자(Principal), 인증 방식, 인증 상태, 권한 목록(GrantedAuthorites)등을 포함한다.➡️ 한 요청 내에서 현재 인증된 사용자가 누구인지, 어떤 권한을 가지는지 알려준다
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
회원가입과 로그인을 실행하면 해당 유저 정보를 JWT에 담고 클라이언트의 호출이 발생하면 토큰에 담겨있던 정보를 빼와서 setAuthentication에 set해준다.
🟢 JwtUtil
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
public String createToken(Long userId, String nickname, String email, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("nickname", nickname)
.claim("email", email)
.claim("userRole", userRole)
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
throw new ServerException("Not Found Token");
}
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
🟢 SecurityConfig
Spring Security 설정 파일이다.
본 포스팅에서는 stateless한 특성을 가지는 JWT를 기반으로 Security 적용을 하기 때문에 세션과 관련된 Filter들을 비활성화 해준다.
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
.formLogin(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
1️⃣
JWT 인증 방식에서 서버가 Session을 관리하지 않기 때문에 STATELESS 설정을 해주어야한다..sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) )
2️⃣ jwtAuthenticationFilter를 기존 필터 체인에 추가하고 SecurityContextHolderAwareRequestFilter보다 앞에서 실행되도록 한다.
.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)➡️ 요청이 들어오면 우선적으로
JWT를 검증하고 검증이 통과되면 SecurityContext에 인증 정보를 설정할 수 있도록 한다.
3️⃣ 폼 기반 로그인 기능이 필요하지 않기 때문에 비활성화 해준다.
.formLogin(AbstractHttpConfigurer::disable)💡 폼 기반 로그인?
4️⃣ 인증은 JWT를 진행하기 때문에 익명 사용자 권한은 필요없다.
.anonymous(AbstractHttpConfigurer::disable)
5️⃣ 커스텀 필터를 사용하기 때문에 필요없다.
.httpBasic(AbstractHttpConfigurer::disable)
6️⃣ 로그아웃은 세션 정보를 지우는 요청이기 때문에 비활성화 해준다.
.logout(AbstractHttpConfigurer::disable)
7️⃣ Stateless이기 때문에 Remember를 할 수 없다.
.rememberMe(AbstractHttpConfigurer::disable)
8️⃣ 요청 정보에서
/auth로 시작하는 URL 경로는 누구나 접근 가능.authorizeHttpRequests(auth -> auth .requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll() // 로그인 or 회원가입 .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() )
➡️
.anyRequest().authenticated():permitAll() 설정 외의 모든 요청은 인증이 필요하다.
🟢 JwtAuthenticationFilter
💡
OncePerRequestFilterSpring Security 필터의 구현체로 모든 요청마다 한 번만 실행된다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain chain
) throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String jwt = jwtUtil.substringToken(authorizationHeader);
try {
Claims claims = jwtUtil.extractClaims(jwt);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
setAuthentication(claims); // 인증된 사용자 저장
}
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (Exception e) {
log.error("Internal server error", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
chain.doFilter(request, response);
}
private void setAuthentication (Claims claims) {
//JWT 토큰에 담긴 유저 정보 추출
Long userId = Long.parseLong(claims.getSubject());
String email = claims.get("email", String.class);
UserRole userRole = UserRole.of(claims.get("userRole", String.class));
//유저 저장
AuthUser authUser = new AuthUser(userId, email, userRole);
//SecurityContext에 인증 정보 저장
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(authUser);
SecurityContextHolder.getContext().setAuthentication(jwtAuthenticationToken); //ContextHolder에 인증 정보 저장
}
🟢 JwtAuthenticationToken
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final AuthUser authUser;
public JwtAuthenticationToken(AuthUser authUser) {
super(authUser.getAuthorities());
this.authUser = authUser;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return authUser;
}
}
🟢 AuthUser 인증된 유저 정보 저장
@Getter
public class AuthUser {
private final Long id;
private final String email;
private final Collection<? extends GrantedAuthority> authorities; //여러 권한을 가질 수 있기 때문에 컬랙션으로 선언
public AuthUser(Long id, String email, UserRole userRole) {
this.id = id;
this.email = email;
this.authorities = List.of(new SimpleGrantedAuthority(userRole.name())); //인증된 유저의 권환을 Security에서 사용할 수 있는 권한 객체로 변환
}
}
💡 Security는 ROLE_USER 와 같은 방식으로 권한을 받기 때문에 아래와 같이 형식 변경
🟢 UserRole
@Getter
@RequiredArgsConstructor
public enum UserRole {
ROLE_ADMIN(Authority.ADMIN),
ROLE_USER(Authority.USER);
private final String role;
public static UserRole of(String role) {
return Arrays.stream(UserRole.values())
.filter(r -> r.name().equalsIgnoreCase(role))
.findFirst()
.orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole"));
}
public static class Authority {
public static final String USER = "ROLE_USER";
public static final String ADMIN = "ROLE_ADMIN";
}
}
AuthUser의 권한 필드 타입이 GrantedAuthority이기 때문에 해당 타입으로 변환을 해야 Security를 통해 검증된 유저의 Role을 얻을 수 있다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
필드 및 생성자 부분 생략
...
public static User fromAuthUser(AuthUser authUser) {
GrantedAuthority authorities = authUser.getAuthorities().iterator().next(); //AuthUser에 저장되어 있던 필드의 타입으로 변경
return new User(authUser.getId(), authUser.getEmail(), UserRole.of(authorities.getAuthority()));
}
...
}
인증된 유저 객체를 가져온다.
@PostMapping("/todos/{todoId}/comments")
public ResponseEntity<CommentSaveResponse> saveComment(
@AuthenticationPrincipal AuthUser authUser,
@PathVariable long todoId,
@Valid @RequestBody CommentSaveRequest commentSaveRequest
) {
return ResponseEntity.ok(commentService.saveComment(authUser, todoId, commentSaveRequest));
}
@Secured(UserRole.Authority.ADMIN) 적용 시 ADMIN 권한을 가진 사람만 해당 API에 인증된다.
@Secured(UserRole.Authority.ADMIN)
@PatchMapping("/admin/users/{userId}")
public void changeUserRole(@PathVariable long userId, @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
userAdminService.changeUserRole(userId, userRoleChangeRequest);
}

🟢 TestSecurityContextFactory
/**
* 테스트 환경에서 SecurityContext를 설정하는 클래스
* WithMockAuthUser 어노테이션을 기반으로 가짜 인증 정보를 생성한다.
*/
public class TestSecurityContextFactory implements WithSecurityContextFactory<WithMockAuthUser> {
/**
* WithMockAuthUser 어노테이션을 통해 받은 유저 정보를 바탕으로 SecurityContext 생성
*
* @param customUser WithMockAuthUser를 통해 받아온 유저 정보(userId, email, role)
* @return SecurityContext 가짜 유저 인증 정보를 담은 context
*/
@Override
public SecurityContext createSecurityContext(WithMockAuthUser customUser) {
//비어있는 새로운 SecurityContext 객체 생성
SecurityContext context = SecurityContextHolder.createEmptyContext();
//유저 객체 생성
AuthUser authUser = new AuthUser(customUser.userId(), customUser.email(), customUser.role());
JwtAuthenticationToken authentication = new JwtAuthenticationToken(authUser);
//SecurityContext에 유저 인증 정보 저장
context.setAuthentication(authentication);
return context;
}
}
🟢 WithMockAuthUser
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = TestSecurityContextFactory.class)
public @interface WithMockAuthUser {
long userId();
String email();
UserRole role();
}

@WithMockAuthUser를 통해 생성한 가짜 인증 유저를 Security Context에 저장하면 테스트에서는 해당 유저를 가져와서 활용하면 된다.
💡 Spring에서 기본적으로 제공하는 @WithMockUser@WithMockUser 어노테이션을 사용해도된다.
커스텀과 다른 점은 이미 어노테이션 내에 각 정보가 default로 정해져있기 때문에 테스트에서 따로 값을 넣어주지 않아도 된다.