
인증과 인가등의 보안 기능을 추가해주어야 할 때가 있다.
이번에는 보안관련 용어와 개념, 그리고 스프링에 보안을 적용할 때 사용하는 스프링 시큐리티에 대해 알아보자.
보안 용어
인증(authentication)
사용자가 누구인지 확인하는 단계를 의미한다.
- 예: 로그인
- 로그인: DB에 등록된 아이디와 패스워드를 입력한 아이디와 비밀번호와 비교해서 일치 여부를 확인하는 과정
- 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달할 수 있다.
인가(authorization)
인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지 확인하는 과정을 의미
- 예: 게시판 글 접근
- 접근 등급을 확인해 허가나 거부를 할 수 있다.
사용자가 리소스에 접근하면서 토큰을 함께 전달하면 서버는 토큰을 통해 권한 유무 등을 확인해 인가를 수행한다.
일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함한다.
접근 주체(principal)
애플리케이션의 기능을 사용하는 주체를 의미한다.
접근 주체는 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수도 있다.
애플리케이션은 은증으로 접근 주체가 신뢰할 수 있는지 확인하고, 인가로 접근 주체에게 부여된 권한을 확인한다.
스프링 시큐리티
스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나다.
- 보안 관련 기능을 제공하기 때문에 스프링 시큐리티를 활용하면 편리하게 원하는 기능을 설계할 수 있다.
스프링 시큐리티의 동작 구조
- 스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작한다.
- DispatcherServelet 앞에 필터가 배치되어 있다.

- 필터체인(FilterChain): 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.
- 클라이언트에서 요청을 보내면 서블릿 컨테이너는 URI를 확인해 필터와 서블릿을 매핑한다.
- 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다.
DelegatingFilterProxy
- 서블릿 컨테이너의 생명 주기와 스프링 애플리케이션 컨텍스트사이에서 다리 역할을 수행하는 필터 구현체다.
- 표준 서블릿 필터를 구현
- 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가지고 있다.
FilterChainProxy
스프링 시큐리티에서 제공하는 필터로서 보안 필터체인 (SecurityFilterChain)을 통해 많은 보안 필터 (Security Filter)을 사용할 수 있다.
- 스프링 부트의 자동 설정에 의해 자동 생성된다.
- 필터체인 프록시에서 사용할 수 있는 보안 필터체인은 List형식으로 담을 수 있게 설정되어 있다.
- 패턴에 따라 특정 보안 필터체인을 선택하여 사용하면 된다.

보안필터 체인에서 사용하는 필터(실행 순)
보안 필터체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다르다.
다음은 보안 필터체인에서 사용되는 필터의 목록이다.
- ChannelProcessingFilter: 요청의 채널(HTTPS, HTTP)을 처리하는 필터
- WebAsyncManagerIntegrationFilter: 웹 비동기 관리를 위한 필터
- SecurityContextPersistenceFilter: 보안 컨텍스트를 영속화하는 필터
- HeaderWriterfilter: 응답 헤더를 작성하는 필터
- CorsFilter: Cross-Origin Resource Sharing을 처리하는 필터
- CsrfFilter: Cross-Site Request Forgery를 방지하는 필터
- LogoutFilter: 로그아웃을 처리하는 필터
- OAuth2AuthorizationRequestRedirectFilter: OAuth2 인증 요청 리디렉션을 처리하는 필터
- Saml2WebSsoAuthenticationRequestFilter: SAML 2.0 웹 SSO 인증 요청을 처리하는 필터
- X509AuthenticationFilter: X.509 인증을 처리하는 필터
- AbstractPreAuthenticatedProcessingFilter: 사전 인증 처리를 위한 추상 필터
- CasAuthenticationFilter: CAS 인증을 처리하는 필터
- OAuth2LoginAuthenticationFilter: OAuth2 로그인 인증을 처리하는 필터
- Saml2WebSsoAuthenticationFilter: SAML 2.0 웹 SSO 인증을 처리하는 필터
- UsernamePasswordAuthenticationFilter: 사용자 이름과 비밀번호 인증을 처리하는 필터
- OpenIDAuthenticationFilter: OpenID 인증을 처리하는 필터
- DefaultLoginPageGeneratingFilter: 기본 로그인 페이지를 생성하는 필터
- DefaultLogoutPageGeneratingFilter: 기본 로그아웃 페이지를 생성하는 필터
- ConcurrentSessionFilter: 동시 세션 관리를 처리하는 필터
- DigestAuthenticationFilter: 다이제스트 인증을 처리하는 필터
- BearerTokenAuthenticationFilter: 베어러 토큰 인증을 처리하는 필터
- BasicAuthenticationFilter: 기본 인증을 처리하는 필터
- RequestCacheAwareFilter: 요청 캐시를 인식하는 필터
- SecurityContextHolderAwareRequestFilter: 보안 컨텍스트 홀더 인식 필터
- JaasApiIntegrationFilter: JAAS API 통합 필터
- RememberMeAuthenticationFilter: Remember-Me 인증을 처리하는 필터
- AnonymousAuthenticationFilter: 익명 사용자 인증을 처리하는 필터
- OAuth2AuthorizationCodeGrantFilter: OAuth2 인증 코드 그랜트를 처리하는 필터
- SessionManagementFilter: 세션 관리를 처리하는 필터
- ExceptionTranslationFilter: 예외 변환을 처리하는 필터
- FilterSecurityInterceptor: 필터 보안 인터셉터
- SwitchUserfilter: 사용자 전환을 처리하는 필터
이러한 필터들은 Spring Security에서 제공하는 다양한 보안 기능을 구현하고 관리하는 데 사용된다.
보안 필터체인은 WebSecurityConfigurerAdapter클래스를 상속받아 설정할 수 있다.
필터체인 프록시는 여러 보안 필터체인을 가질 수 있다.
여러 보안 필터체인을 만들기 위해선 WebSecurityConfigurerAdapter클래스를 상속받는 클래스를 여러 개 생성하면 된다.
이때 WebSecurityConfigurerAdapter 클래스에는 @Order어노테이션을 통해 우선 순위가 지정된다.
2개 이상의 동일 설정으로 우선 순위가 100으로 설정되면 예외가 발생하기 때문에 상속 받은 클래스에서 어노테이션을 지정해 순서를 정의하는 것이 중요하다.
별도의 설정이 없다면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.
UsernamePasswordAuthenticationFilter를 통한 인증 수행 과정

- 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain 으로 작업이 위임되고, 그중에서 UsernamePasswordAuthenticationFilter(=AuthenticationFilter에 해당)에서 인증을 처리
- AuthenticationFilter는 요청 객체(HttpServletRequest)에서 사용자 정보(username, password)를 추출해 토큰을 생성
- 그 후에 생성된 AuthenticationManager에게 전달됩니다.
- AuthenticationManagersms는 인터페이스
- 일반적으로 사용되는 구현체는 ProviderManager이다.
- AuthenticationManagersms는 인터페이스
- ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달
- AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달
- UserDetailsService는 토큰의 정보를 클레임으로 파싱해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성
- 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하면 ProviderManager로 권한을 담은 토큰을 전달
- ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달
- AuthenticationFilter는 검증된 토큰을 SecurityContextHoler에 있는 SecurityContext에 저장
에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 로그인 폼이라는 화면을 보내는 역할을 수행한다. 실습 중인 프로젝트는 화면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야 한다.
JWT 토큰을 사용해 인증을 하기위해
- JWT와 관련된 필터를 생성하고
- UsernamePasswordAuthenticationFilter 앞에 배치하여 먼저 인증을 수행할 수 있도록 설정하면 된다.
UsernamePasswordAuthenticationFilter에서의 단어
- SecurityFilterChain: 보안 필터 체인
- UsernamePasswordAuthenticationFilter: 사용자 이름과 비밀번호 인증을 처리하는 필터
- AuthenticationManager: 인증 매니저
- ProviderManager: 프로바이더 매니저
- AuthenticationProvider: 인증 제공자
- UserDetailsService: 사용자 정보를 제공하는 서비스
- UserDetails: 사용자의 상세 정보
- SecurityContextHolder: 보안 컨텍스트를 관리하는 클래스
- SecurityContext: 보안 컨텍스트
JWT
JWT(JSON Web Token): 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰.
- URL로 이용할 수 있는 문자열로만 구성되어 있다.
=> HTTP 구성 요소 어디든 위치할 수 있다. - 디지털 서명이 적용되어 신뢰할 수 있다.
- 주로 서버와의 통신에서 권한 인가를 위해 사용된다.
JWT의 구조
JWT는 일반적으로 아래와 같은 형식을 띈다.

헤더(Header)
JWT의 헤더는 검증과 관련된 내용을 담고 있다.
헤더는 두 가지 정보(alg와 typ속성)를 포함하고 있다.
{
"alg": "HS256",
"typ": "JWT"
}
- "alg" 속성
- 해싱 알고리즘을 지정
- 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용
- 토큰을 검증할 때 사용되는 서명 부분에서 사용
- 위 예제에 작성된 HS256은 'HMAC SHA256' 알고리즘을 사용한다는 의미
- 해싱 알고리즘을 지정
- "typ" 속성
- 토큰의 타입을 지정
이렇게 완성된 헤더는 Base64Url 형식으로 인코딩되어 사용된다.
내용(Payload)
JWT의 내용은 토큰에 담는 정보를 포함한다.
이 곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류된다.
{
"iss": "example.com",
"sub": "user123",
"username": "john_doe",
"userId": 987654321,
"aud": "api.example.com",
"exp": 1672518325,
"nbf": 1672514725,
"iat": 1672514725,
"jti": "abc123"
}
"iss": 발급자는 "example.com"입니다.
"sub": 제목은 "user123"입니다.
"username": 사용자 이름은 "john_doe"입니다.
"userId": 사용자 식별자는 987654321입니다.
"aud": 수신인은 "api.example.com"입니다.
"exp": 만료 시간은 1672518325입니다. (Unix 시간 형식)
"nbf": Not Before는 1672514725입니다. (Unix 시간 형식)
"iat": 발급 시간은 1672514725입니다. (Unix 시간 형식)
"jti": JWT ID는 "abc123"입니다.
완성된 내용은 Base64Url형식으로 인코딩되어 사용된다.
등록된 클레임 (Registered Claims)
토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임(필수는 아니다)
등록된 클레임 정의
- iss: JWT의 발급자(Issuer). iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다.
- sub: JWT의 제목 (Subject).
- aud: JWT의 수신인 (Audience). JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 한다. 요청을 처리하는 주체가 aud 값으로 자신을 식별하지 않으면 JWT는 거부
- exp: JWT의 만료 시간 (Expiration). 시간은 NumericDate 형식
- nbf: Not Before
- iat: JWT가 발급된 시간 (Issued at)
- jti: JWT의 식별자 (JWT ID). 주로 중복 처리를 방지하기 위해 사용
공개 클레임 (Public Claims)
키 값을 마음대로 정의 가능하다.
다만, 충돌이 발생하지 않을 이름으로 설정해야 한다.
비공개 클레임 (Private Claims)
통신 간에 상호 합의하고 등록된 클레임과 공개된 클레임이 아닌 클레임
서명(Signature)
JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀 키, 그리고 헤더의 알고리즘 속성 값으로 생성된다.
- 서명은 토큰의 값들을 포함해 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용된다.
아래는 HMAC SHA256 알고리즘을 사용하여 서명을 생성했을 때 서명 생성 방식이다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
JWT 디버거 사용하기
JWT 공식 사이트에서는 더욱 쉽게 JWT를 생성할 수 있습니다. 웹 브라우저에서 다음 URL로 접속하면 아래와 같은 화면을 볼 수 있다.
Encoded와 Decoded로 나눠져 있고, 양측 내용 일치 여부를 확인할 수 있고, Decoded의 내용을 변경하면 Encoded의 콘텐츠가 자동 반영된다고 한다.
https://jwt.io/#debugger-io

스프링 시큐리티와 JWT 적용
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
UserDetails 구현
UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetailService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 한다.
UserDetails의 username은 각 사용자를 구분할 수 있는 식별자를 의미한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
private static final long serialVersionUID = 6014984039564979072L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, unique = true)
private String uid; // 회원 ID (JWT 토큰 내 정보)
@JsonProperty(access = Access.WRITE_ONLY) // Json 결과로 출력하지 않을 데이터에 대해 해당 어노테이션 설정 값 추가
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
/**
* security 에서 사용하는 회원 구분 id
*
* @return uid
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
/**
* 계정이 만료되었는지 체크하는 로직
* 이 예제에서는 사용하지 않으므로 true 값 return
*
* @return true
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정이 잠겼는지 체크하는 로직
* 이 예제에서는 사용하지 않으므로 true 값 return
*
* @return true
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 계정의 패스워드가 만료되었는지 체크하는 로직
* 이 예제에서는 사용하지 않으므로 true 값 return
*
* @return true
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 계정이 사용가능한지 체크하는 로직
* 이 예제에서는 사용하지 않으므로 true 값 return
*
* @return true
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
각 메서드의 용도를 정리하면 다음과 같습니다:
- getAuthorities(): 계정이 가지고 있는 권한 목록을 반환
- getPassword(): 계정의 비밀번호를 반환
- getUsername(): 계정의 이름을 반환. 일반적으로 아이디를 반환
- isAccountNonExpired(): 계정이 만료되었는지 반환. 'true'은 만료되지 않았음을 의미
- isAccountNonLocked(): 계정이 잠겨 있는지 반환. ' true'는 잠기지 않았음을 의미
- isCredentialNonExpired(): 비밀번호가 만료되었는지 반환. ' true'은 만료되지 않았음을 의미
- isEnabled(): 계정이 활성화되어 있는지 반환. ' true'은 활성화 상태를 의미
UserDetailsService 구현
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
return userRepository.getByUid(username);
}
}
username을 가지고 UserDetails 객체를 반환하도록 정의되어 있는데, UserDetails의 구현체로 User 엔티티를 생성했기 때문에 User 객체를 반환하도록 구현했다.
JwtTokenProvider 구현
이제 JWT 토큰을 생성하는 데 필요한 정보를 UserDetails에서 가져올 수 있기 때문에 JWT 토큰을 생성하는 개념인 JwtTokenProvider를 생성한다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService; // Spring Security 에서 제공하는 서비스 레이어
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60; // 1시간 토큰 유효
/**
* SecretKey 에 대해 인코딩 수행
*/
@PostConstruct
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
System.out.println(secretKey);
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
System.out.println(secretKey);
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
// JWT 토큰 생성
public String createToken(String userUid, List<String> roles) {
LOGGER.info("[createToken] 토큰 생성 시작");
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret 값 세팅
.compact();
LOGGER.info("[createToken] 토큰 생성 완료");
return token;
}
// JWT 토큰으로 인증 정보 조회
public Authentication getAuthentication(String token) {
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}",
userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(userDetails, "",
userDetails.getAuthorities());
}
// JWT 토큰에서 회원 구별 정보 추출
public String getUsername(String token) {
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
.getSubject();
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
/**
* HTTP Request Header 에 설정된 토큰 값을 가져옴
*
* @param request Http Request Header
* @return String type Token 값
*/
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
// JWT 토큰의 유효성 + 만료일 체크
public boolean validateToken(String token) {
LOGGER.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
LOGGER.info("[validateToken] 토큰 유효 체크 완료");
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
secretKey
토큰 생성을 위해선 secretKey가 필요하다.
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
@Value의 값은 application.properties파일에서 정의할 수 있다.
springboot.jwt.secret=flature!@#
application.properties파일에서 값을 가져오는걸 실패하면 기본값으로 입력한 'secretKey'를 가져온다.
init()
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
}
- @PostConstruct
- 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드
@Component가 설정되어 있어서 애플리케이션이 가동되면서 빈으로 자동 주입된다. 이때 해당 메서드가 자동 실행된다.
init()메서드는 secretKey를 Base64형식으로 인코딩한다.
createToken()
public String createToken(String userUid, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret 값 세팅
.compact();
return token;
}
JWT토큰의 내용에 값을 넣기위해 Claims 객체를 생성한다.
sub에 userUid 값 설정, 사용자권한 roles 설정 후 Jwts.builder로 토큰을 생성한다.
getAuthentication()
필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성
UsernamePasswordAuthenticationToken을 사용하면 Authentication을 편하게 구현 가능하다.
// JWT 토큰으로 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}

getUsername()
// JWT 토큰에서 회원 구별 정보 추출
public String getUsername(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
.getSubject(); // info
}
sub값 추출: Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 토큰을 생성할 때 넣었던 sub값을 추출한다.
resolveToken()
/**
* HTTP Request Header 에 설정된 토큰 값을 가져옴
*
* @param request Http Request Header
* @return String type Token 값
*/
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
HttpServeletRequest를 파라미터로 받고 헤더 값의 X-AUTH-TOKEN을 추출한다.
(클라이언트가 JWT토큰 값을 전달해야 정상 추출이 가능하다. 헤더 이름은 변경 가능하다.)
validateToken()
// JWT 토큰의 유효성 + 만료일 체크
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());// 토큰 유효 체크 완료
} catch (Exception e) {
return false; // 토큰 유효 체크 예외 발생
}
}
클레임의 유효기간을 체크한다.
JwtAuthenticationFilter
JWT로 인증을하고 SecurityContextHolder에 필터를 추가하기 위한 클래스다.
필터를 상속받아 사용하는 것이 편한 구현 방법이다.
대표적인 상속 객체는 GenericFilterBean과 OncePerRequestFilter이다.
OncePerRequestFilter구현
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(servletRequest);
LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(servletRequest, servletResponse); // 서블릿을 실행하는 메서드
}
}
doFilter(): 서블릿을 실행하는 메서드
doFilter메서드를 기준으로 앞은 서블릿이 실행되기 전에 실행되고 뒤는 서블릿이 실행된 후에 실행된다.
내부 로직은 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사한다.
토큰이 유효하면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.
GenericFilterBean 구현
GenericFilterBean: 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상클래스다.
서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해두고 동일한 클라이언트의 요청을 받으면 재활용하는 구조다.
GenericFilterBean을 상속받으면 RequestDispatcher 에 의해 다른 서블릿으로 디스패치 되면 필터가 두번 발생할 수 있다.
이를 해결하기 위해 등장한 것이 OncePerRequestFilter이고, 이 클래스 역시 GenericFilterBean을 상속받고 있다.
이 클래스를 상속받아 구현한 필터는 매 요청마다 한번만 실행되게끔 구현된다.
public class JwtAuthenticationFilter extends GenericFilterBean {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
LOGGER.info("[doFilterInternal] token retrieved: {}", token);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] Authentication set for token");
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
SecurityConfiguration - WebSecurityConfigureAdapter 상속
스프링 시큐리티 관련 설정을 한다.(위에까진 스프링 시큐리티를 위한 컴포넌트를 구현했다.)
WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것이 스프링 시큐리티 설정의 대표적인 방법이다.
/**
* 어플리케이션의 보안 설정
*
* @author Flature
* @version 1.0.0
*/
@Configuration
//@EnableWebSecurity // Spring Security에 대한 디버깅 모드를 사용하기 위한 어노테이션 (default : false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity.httpBasic().disable() // REST API는 UI를 사용하지 않으므로 기본설정을 비활성화
.csrf().disable() // REST API는 csrf 보안이 필요 없으므로 비활성화
.sessionManagement()
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS) // JWT Token 인증방식으로 세션은 필요 없으므로 비활성화
.and()
.authorizeRequests() // 리퀘스트에 대한 사용권한 체크
.antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
"/sign-api/exception").permitAll() // 가입 및 로그인 주소는 허용
.antMatchers(HttpMethod.GET, "/product/**").permitAll() // product로 시작하는 Get 요청은 허용
.antMatchers("**exception**").permitAll()
.anyRequest().hasRole("ADMIN") // 나머지 요청은 인증된 ADMIN만 접근 가능
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JWT Token 필터를 id/password 인증 필터 이전에 추가
}
/**
* Swagger 페이지 접근에 대한 예외 처리
*
* @param webSecurity
*/
@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**", "/sign-api/exception");
}
}
HttpSecurity를 통한 configure()
- 리소스 접근 권한 설정
- 인증 실패 시 발생하는 예외 처리
- 인증 로직 커스터마이징
- csrf, cors 등의 스프링 시큐리티 설정
configure 코드
- httpBasic().disable(): UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화
- csrf().disable(): REST API에서는 CSRF(Cross-Site Request Forgery) 방지가 필요 없으므로 비활성화
CSRF는 웹 애플리케이션의 취약점 중 하나로, 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 하게 만들거나 수정하는 공격 방법이다. 스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급하고 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작한다. 브라우저 사이 사용 환경이 아니라면 비활성화해도 큰 문제가 되지 않는다.
- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): REST API 기반 애플리케이션의 동작 방식을 설정. 현재 프로젝트에서는 JWT 토큰으로 인증을 처리하며, 세션을 사용하지 않기 때문에 STATELESS로 설정.
- authorizeRequest(): 애플리케이션에 들어오는 요청에 대한 사용권한을 체크. 이어서 사용한 antMatchers() 메서드는 antPattern을 통해 권한을 설정.
- exceptionHandling().accessDeniedHandler(): 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달합니다.
- exceptionHandling().authenticationEntryPoint(): 인증 과정에서 예외가 발생할 경우 예외를 전달합니다.
각 메서드는 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달한다.
'spring > spring' 카테고리의 다른 글
| [Spring][RabbitMQ] 설정하고 실행해보기 (0) | 2024.04.22 |
|---|---|
| [Spring][Exception] Controller Ambiguous mapping (0) | 2024.04.15 |
| [Spring][스프링 부트 핵심 가이드] 액추에이터 활용하기 (0) | 2024.03.06 |
| [Spring][스프링 부트 핵심 가이드] 서버 간 통신 (0) | 2024.03.05 |
| [Spring][Exception] Failed to configure a DataSource (0) | 2024.03.04 |