Spring Security
- Spring security를 사용하여 인증/인가 시스템을 구현하였습니다.
- Spring Sercurity는 Dispatcher Servlet으로 요청이 도달하기 전 Filter 구간에서 동작합니다.
❓Spring Security를 사용한 이유
Client에서 보낸 요청에서 Cookie나 Header를 통해 Controller Layer에서 인증/인가를 구현할 수 있습니다.
하지만 요청한 기능에 대한 응답을 하는 Controller Layer에서 인증/인가를 구현하는 것 보다
Filter 단계에서 구현 하는 것이 더 효율적입니다. 또한 Spring Security는 인증/인가에 관한 일련의 행동들을
FilterChain을 Bean으로 등록함으로써 간편하게 구현할 수 있어 Sprnig Security를 사용하고자 하였습니다.
FlowChart
위 사진은 제가 구현한 Spring Security 인증/인가 Flowchart입니다.
- SecurityFilterChain에서 JwtToken을 사용한 인가를 지원하는 JwtAuthenticationFilter 구현
- Client에서 보낸 Token 추출 및 파싱
- 파싱한 Token의 payload를 UserNamePasswordAuthenticationToken 객체 형태로 파싱
- SecurityContextHolder에 저장하기 위해 Authentication을 상속하고 있는 UserNamePasswordAuthentication 형태로 저장
1. SecurityFilterChain ( Security Configuration )
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private static final String LOGIN_URI = "/account/login";
private static final String SIGNUP_URI = "/account/signup";
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()//jwt Token 방식이므로 서버에 인증 정보를 저장하지 않으므로 csrf를 disable한다.
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션을 사용하지 않는다.
.and()
.httpBasic().disable() //username, password 사용하는 basic 방식 X Token 사용하는 Bearer 방식
.formLogin().disable() // 기본 제공 로그인 폼 사용하지 않는다
.logout().disable() // 기본 제공 로그아웃 폼 사용하지 않는다.
.authorizeHttpRequests()
.antMatchers(POST,LOGIN_URI).permitAll()
.antMatchers(POST,SIGNUP_URI).permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
@EnableWebSecurity Annotation을 사용하면 SecurityFilterChain Bean으로 등록하여 사용할 수 있습니다.
Spring Servlet 단계에서 인증/인가 방법, 인가가 필요한 url, 인가가 필요없는 url 등 filter 단계에서의 기능 정의를 할 수 있습니다.
2. JwtAuthenticationFilter
@Configuration
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenExtractor tokenExtractor;
private final JwtTokenParser tokenParser;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
var a = tokenExtractor.extract(request.getHeader("Authorization")).isPresent();
tokenExtractor.extract(request.getHeader("Authorization")).ifPresent(
extractToken -> {
AuthenticatedAccount account = tokenParser.parse(extractToken);
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken context = new UsernamePasswordAuthenticationToken(
account, null, getGrantedAuthorities(account)
);
SecurityContextHolder.getContext().setAuthentication(context);
}
}
);
filterChain.doFilter(request, response);
}
모든 서블릿 컨테이너에서 요청 디스패치당 한번의 실행을 보장하는 것을 목표로 하는 필터 기본 클래스입니다.
모든 요청은 인증/인가에 관한 내용을 포함해야 하기 때문에 해당 Filter를 상속하여 사용했습니다.
[org.springframework.web.filter (Spring Framework 6.0.7 API)
Filter that parses form data for HTTP PUT, PATCH, and DELETE requests and exposes it as Servlet request parameters.
docs.spring.io](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/package-summary.html)
요청의 Authorization Header에 위치한 JwtToken을 추출하고 AuthenticationAccount Class에 Mapping합니다.
그리고 SecurityContextHolder에 저장하기 위해, UserNameAuthenticationToken 객체 형태로 변환한 뒤 SecurityContextHolder에 저장합니다.
import java.util.List;
public class AuthenticatedAccount {
private final String userId;
private final List<String> role;
public AuthenticatedAccount(String userId, List<String> role) {
this.userId = userId;
this.role = role;
}
public String getUserId() {
return userId;
}
public List<String> getRole() {
return role;
}
}
3. Token 추출 및 파싱
@Service
public class JwtTokenExtractor {
private static final String HEADER_PREFIX = "Bearer ";
public Optional<String> extract(String header) {
if(header == null || header.isBlank()){
return Optional.empty();
}
if(header.length() < 7){
return Optional.empty();
}
return Optional.of(header.substring(HEADER_PREFIX.length()));
}
}
Token은 Bearer (JwtToken) 형태로 Request Header에 위치해있기 때문에 Bearer Prefix를 제거해야 합니다.
import java.util.List;
@Service
public class JwtTokenParser {
private final String SECRET_KEY = "last-order project my name is sagajeong motjange!!!";
public AuthenticatedAccount parse(String token) {
try {
Claims jwt = Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(token)
.getBody();
return new AuthenticatedAccount((String) jwt.get("userId"), (List<String>) jwt.get("role"));
} catch (SignatureException | MalformedJwtException e) {
throw new RuntimeException("Invalid Jwt Token");
} catch (ExpiredJwtException e) {
throw new RuntimeException("Expired Jwt Token");
} catch (UnsupportedJwtException e) {
throw new RuntimeException("UnsupportedJwtException");
}
}
}
JwtToken에 대한 Validation과 함께 Token의 payload에 있는 User에 대한 정보를 AuthentacionAccount 객체에 파싱하기 위한 기능을 구현하였습니다.
4. User 정보 사용
Filter 단계에서 인증/인가를 마치고 SecurityContextHolder에 저장된 유저들의 정보를 각 Controller에서 사용하고 싶을 것입니다.
@PostMapping("/test")
public ApiResponse<?> test() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
AuthenticatedAccount account1 = (AuthenticatedAccount) authentication.getPrincipal();
return ApiResponse.success(account1);
}
위와 같은 코드로 SecurityContextHolder에 있는 내용을 각 메소드마다 꺼내어 사용할 수 있습니다.
하지만 같은 내용의 코드를 유저의 정보를 사용하고 싶은 모든 메소드마다 구현하는 것은 비효율적인 과정입니다.
Spring Security는 해당 작업을 Annotation을 이용하여 쉽게 구현할 수 있습니다.
@PostMapping("/test")
public ApiResponse<?> test(@AuthenticationPrincipal AuthenticatedAccount account) {
return ApiResponse.success(account);
}
@AuthenticationPrincipal Annotation을 사용하면 쉽게 SecurityContextHolder에 있는 내용을 가져올 수 있습니다.
해당 Annotation의 내부를 보면 위의 예제 코드와 같은 동작을 구현합니다.
public final class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
AuthenticationPrincipal annotation = findMethodAnnotation(AuthenticationPrincipal.class, parameter);
String expressionToParse = annotation.expression();
if (StringUtils.hasLength(expressionToParse)) {
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(principal);
context.setVariable("this", principal);
context.setBeanResolver(this.beanResolver);
Expression expression = this.parser.parseExpression(expressionToParse);
principal = expression.getValue(context);
}
if (principal != null && !ClassUtils.isAssignable(parameter.getParameterType(), principal.getClass())) {
if (annotation.errorOnInvalidType()) {
throw new ClassCastException(principal + " is not assignable to " + parameter.getParameterType());
}
return null;
}
return principal;
}
HandlerMethodArgumentResolver는 각 요청 넘어온 인자들을 메소드 파라미터로 해석할 수 있도록 도와주는 인터페이스입니다. @AuthenticationPrincipal의 해석을 도와주는 구현체는 AuthenticationPrincipalArgumentResolver임을 알 수 있습니다. 해당 class의 내용을 보면
Object principal = authentication.getPrincipal();
AuthenticationPrincipal annotation = findMethodAnnotation(AuthenticationPrincipal.class, parameter);
과정을 거치며 Annotation에 있는 클래스와 맵핑하는 과정을 볼 수 있습니다.
이렇게 하여 SpringSecurity를 이용하여 인증/인가하는 과정을 성공적으로 구현하였습니다!!
'프로젝트 > E-Commerce' 카테고리의 다른 글
[5] AccountService (3) 구현 (0) | 2023.04.07 |
---|---|
[3] AccountService (1) 설계 (0) | 2023.04.02 |
[2] System Architecture (0) | 2023.03.31 |
(1) LastOrder Proejct 개요 (0) | 2023.03.31 |