spring security 학습과 Spring boot 에 JWT, OAuth2.0 적용을 위해 간단한 프로젝트를 진행해 보았습니다. 이번 프로젝트로 filter 기반의 Spring Security 아키텍처를 이해하고, JWT 와 OAuth2.0 의 이해 및 적용을 목표로 하였습니다.
JWT 기반 인증
이번 프로젝트는 access token 과 refresh token 을 모두 사용하는 방법을 채택했습니다. access token 과 refresh token 을 사용한 flow 는 다음과 같습니다.
AT : Access Token. 만료기간 짧음, RT : Refresh Token. 만료기간 김
case 0 ) login
/login
- 로그인 성공 시, 200 - memberId, AT1, RT1 response. RT1 은 cookie 에 세팅된다.
- 로그인 실패 시, 401 - 인증 실패 error response
case 1) AT1 만료 X
/request with AT1
- Authorization header 에 Bearer AT1 을 첨부하여 request 한다.
- AT1 검증 후 정상적으로 response
case 2) AT1 만료, RT1 만료 X
/request with AT1
- Authorization header 에 Bearer AT1 을 첨부하여 request 한다.
- 401 - token 만료 error response
- client 는 token 이 만료되었다는 response 확인 후 Authorization header 에 Bearer RT1 첨부하여 request 한다.
/refresh with RT1
- RT1 검증 후 AT2 response
case 3) AT1 만료, RT1 만료
/request with AT1
- 401 - token 만료 error response
- client 는 token 이 만료되었다는 response 확인 후 Authorization header 에 Bearer RT1 첨부하여 request 한다.
/refresh with RT1
- 401 - token 만료 error response
- RT1 이 만료되었다는 것을 확인했으므로, client 는 /login 을 통해 새로운 AT 와 RT 를 받아야 한다.
간단한 구현을 위해 refresh token 의 갱신 주기는 고려하지 않았지만, refresh token 에 갱신 주기를 추가하여 인증 과정을 설계할 수도 있습니다.
Spring Security 에서의 인증 과정
spring security 를 사용한 인증과정에서의 핵심은 AuthenticationManager 인터페이스 입니다. AuthenticationManager 의 authenticate 메서드는 Authentication 객체를 인자로 받아 인증을 시도합니다. 인증이 성공하면 권한을 포함한 인증된 Authentication 객체를 return 하고, 인증이 실패하면 exception 을 throw 합니다. 인증의 과정은 다음과 같습니다.
- AuthenticationManager 인터페이스의 authenticate 메서드를 실행하면 AuthenticationManager 의 구현체의 authenticate 메서드를 실행합니다. 보통 ProviderManager 가 사용됩니다.
- ProviderManager 는 List of AuthenticationProvder 에게 인증을 위임합니다.
- AuthenticationManager 인터페이스의 authenticate 의 인자로 UserPasswordAuthenticationToken 을 받는다면 AuthenticationProvder 의 구현체인 DaoAuthenticationProvider 를 사용합니다.
- DaoAuthenticationProvider 는 UserDetailService 를 사용해 UserDetails 객체를 생성하여 UserPasswordAuthenticationToken 과 비교합니다.
- 인증이 성공하면 권한과 사용자 정보가 담긴 Authentication 객체를 return 합니다.
authenticate 를 사용하여 받아온 Authentication 객체는 authenticatie 를 호출한 곳에서 SecurityContextHolder 에 설정하도록 합니다.
Spring Security 에서 예외가 발생하면 ExceptionTranslationFilter 에서 Exception 을 감지하고 처리합니다. 특히 AuthenticationException 과 AccessDeniedException 을 감지하여 각각 AuthenticationEntrypoint 와 AccessDeniedHandler 에서 Exception 이 다뤄집니다.
구현 코드
CustomUserDetails
public class CustomUserDetails implements OAuth2User, UserDetails {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public CustomUserDetails(Long id, String email, String password, Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static CustomUserDetails create(Member member) {
List<SimpleGrantedAuthority> authorities = Collections
.singletonList(new SimpleGrantedAuthority(member.getRole().getValue()));
return new CustomUserDetails(member.getId(),
member.getEmail(),
member.getPassword(),
authorities);
}
public static CustomUserDetails create(Member member, Map<String, Object> attributes) {
CustomUserDetails userDetails = CustomUserDetails.create(member);
userDetails.setAttributes(attributes);
return userDetails;
}
public Long getId() {
return id;
}
// UserDetail Override
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// OAuth2User Override
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return String.valueOf(id);
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
}
추후 OAuth2 적용 시 같은 객체를 사용하기 위해서 CustomUserDetails 는 UserDetails 와 OAuth2User 모두 구현했습니다. getUsername 이 email 을 return 하도록 하여 추후 UserDetailService 의 loadByUsername 의 인자로 email 을 받을 수 있도록 합니다.
CustomUserDeatilService
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("no member"));
return CustomUserDetails.create(member);
}
public UserDetails loadUserById(Long id) throws UsernameNotFoundException {
Member member = memberRepository.findOne(id)
.orElseThrow(RuntimeException::new);
return CustomUserDetails.create(member);
}
}
loadUserByUsername 메서드는 email 을 사용해 member 를 찾고, member 를 사용해 CustomUserDetails 객체를 생성합니다. 인증과정에서 쓰이지는 않지만 id 를 사용해 CustomUserDetails 객체를 생성해야하는 경우가 있어 loadUserById 메서드도 추가적으로 구현했습니다.
JwtProvider
@Component
@RequiredArgsConstructor
public class JwtProvider {
@Value("${auth.token.secret-key}")
private String SECRET_KEY;
private final Long ACCESS_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60; // 1hour
private final Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L * 60 * 60 * 24 * 7; // 1week
private final String AUTHORITIES_KEY = "role";
private final CustomUserDetailService customUserDetailService;
private String createToken(Authentication authentication, Long tokenExpireLength) {
Date now = new Date();
Date expiration = new Date(now.getTime() + tokenExpireLength);
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
String userId = user.getName();
String role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.setSubject(userId)
.claim(AUTHORITIES_KEY, role)
.setIssuedAt(now)
.setExpiration(expiration)
.compact();
}
public String createAccessToken(Authentication authentication) {
return createToken(authentication, ACCESS_TOKEN_EXPIRE_LENGTH);
}
public String createRefreshToken(Authentication authentication) {
return createToken(authentication, REFRESH_TOKEN_EXPIRE_LENGTH);
}
public Long findMemberId(String token) {
return Long.valueOf(parseClaims(token).getSubject());
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = customUserDetailService.loadUserById(findMemberIdByJwt(token));
return new UsernamePasswordAuthenticationToken(userDetails,
"",
userDetails.getAuthorities());
}
public JwtResultType validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return JwtResultType.VALID_JWT;
} catch (ExpiredJwtException e) {
System.out.println("만료된 JWT 토큰입니다.");
return JwtResultType.EXPIRED_JWT;
} catch (Exception e) {
System.out.println("JWT 토큰이 잘못되었습니다");
return JwtResultType.INVALID_JWT;
}
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JWT 를 생성, 검증하는 클래스입니다. 추가적으로 토큰을 입력받아 MemberId 를 return 하는 findMemberId 메서드와 Authentication 을 return 하는 getAuthentication 메서드가 있습니다.
JwtResultType
public enum JwtResultType {
VALID_JWT, EXPIRED_JWT, INVALID_JWT
}
JWT 를 검증한 결과를 정의한 enum 입니다. ExpiredJwtException 은 다른 Exception 들과 다른 로직을 처리해야하기 때문에, enum 을 정의하여 JwtProvider 클래스의 validateToken 메서드의 return 값으로 사용했습니다.
JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final String TOKEN_PREFIX = "Bearer ";
private final JwtProvider jwtProvider;
private final List<String> EXCLUDE_URL_PATTERN = List.of(
"/api/login",
"/api/signup",
"/api/admin/signup",
"/oauth2/**"
);
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String pathName = request.getServletPath();
return EXCLUDE_URL_PATTERN.stream().anyMatch(url -> {
if (url.endsWith("/**")) {
return pathName.startsWith(url.substring(0, url.length() - 3));
} else {
return url.equalsIgnoreCase(pathName);
}
});
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
JwtResultType jwtResultType = jwtProvider.validateToken(token);
if (jwtResultType == JwtResultType.VALID_JWT) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} else if (jwtResultType == JwtResultType.EXPIRED_JWT) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
JSONObject json = new JSONObject();
json.put("code", 401);
json.put("message", "Access Token 이 만료되엇습니다.");
response.getWriter().print(json);
} else {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
JSONObject json = new JSONObject();
json.put("code", 400);
json.put("message", "잘못된 JWT 입니다.");
response.getWriter().print(json);
}
} else {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
JSONObject json = new JSONObject();
json.put("code", 400);
json.put("message", "JWT 가 존재하지 않습니다.");
response.getWriter().print(json);
}
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
}
request 의 header 에서 token 을 가져와 검증하는 filter 입니다. shouldNotFilter 메서드는 request 의 path name 이 EXCLUDE_URL_PATTERN 과 매칭된다면 true 를 return 하여 filtering 를 하지 않도록 합니다.
doFilterInternal 메서드는 JWT 를 가져와 검증하여, 유효한 JWT 라면 Authentication 을 생성해 SecurityContext 에 저장합니다. 검증되지 않는다면 error 를 response 합니다.
CustomAuthenticationEntryPoint
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
JSONObject json = new JSONObject();
json.put("code", 401);
json.put("message", authException.getLocalizedMessage());
response.getWriter().print(json);
}
}
인증이 실패한 경우 401 status 와 error message 를 response 하도록 설정했습니다.
CustomDeniedHandler
@Component
public class CustomDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
JSONObject json = new JSONObject();
json.put("code", 403);
json.put("message", accessDeniedException.getLocalizedMessage());
response.getWriter().print(json);
}
}
권한이 없는 경우 403 status 와 error message 를 response 하도록 설정했습니다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtProvider jwtProvider;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomDeniedHandler customDeniedHandler;
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_MANAGER > ROLE_MEMBER > ROLE_GUEST");
return roleHierarchy;
}
@Bean
public AccessDecisionVoter<? extends Object> roleVoter() {
RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
return roleHierarchyVoter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/api/login", "/api/signup", "/api/admin/signup", "/oauth2/**")
.permitAll()
.antMatchers("/api/refresh")
.authenticated()
.antMatchers(HttpMethod.GET, "/api/my").hasRole("MEMBER")
.antMatchers("/api/admin/my").hasRole("ADMIN")
.anyRequest()
.authenticated()
.and()
.addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
AuthenticationManager 의 Bean 을 반드시 생성해 주어야 합니다. Bean 생성이 없으면 exception 이 발생합니다.
roleHierarchy 는 권한에 계층을 설정해 주고, roleVoter 는 roleHierachy 를 사용해 해당 계층을 등록해 줍니다. 우리는 ROLE_ADMIN 은 ROLE_MEMBER 의 모든 권한을 가지기 원합니다. 하지만 이러한 계층 설정이 없다면, ROLE_ADMIN 은 ROLE_ADMIN 의 권한만 가지게 됩니다. 따라서 권한 계층을 등록하여 상위 권한은 하위 권한을 가지도록, 즉 ROLE_ADMIN 은 ROLE_MEMBER 의 권한을 모두 가지도록 설정해줍니다.
restful api 를 개발중이므로 csrf(), formLogin(), httpBasic() 을 모두 disable() 처리하고 session 을 stateless 하도록 설정합니다. 앞서 선언한 CustomAuthenticationEntryPoint 와 CustmDeniedHandler 를 등록해줍니다. antMatchers 를 통해 특정 Url Pattern 마다 다른 속성을 부여합니다. permitAll() 은 인증이 없이 모두 통과하고, authenticated() 는 인증 필요, hasRole() 은 인자로 받은 권한을 가지는 경우만 통과시킵니다. 마지막으로 앞서 정의한 JwtFilter 를UsernamePasswordAuthenticationFilter 앞에 등록합니다.
AuthController
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final RefreshTokenService refreshTokenService;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
@PostMapping("/api/login")
public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequestDto.getEmail(),
loginRequestDto.getPassword())
);
SecurityContextHolder.getContext().setAuthentication(authentication);
String accessToken = jwtProvider.createAccessToken(authentication);
String refreshToken = jwtProvider.createRefreshToken(authentication);
Member member = authService.getMemberByEmail(authentication.getName());
refreshTokenService.update(member, refreshToken);
CookieUtil.addCookie(response, "REFRESH_TOKEN", refreshToken, 60 * 60 * 24 * 7);
return ResponseEntity.ok(new LoginResponseDto(member.getId(), accessToken));
}
@PostMapping("/api/signup")
public ResponseEntity<Void> signup(@RequestBody SignupRequestDto signupRequestDto) {
if (!authService.memberValidCheck(signupRequestDto.getEmail())) {
throw new RuntimeException("멤버 있음");
}
Member member = Member.builder()
.name(signupRequestDto.getName())
.sex(signupRequestDto.getSex())
.birthday(signupRequestDto.getBirthday())
.email(signupRequestDto.getEmail())
.password(passwordEncoder.encode(signupRequestDto.getPassword()))
.role(MemberRole.MEMBER)
.authProvider(AuthProvider.EMAIL)
.build();
Long memberId = authService.save(member);
return ResponseEntity.created(URI.create("/api/members/" + memberId)).build();
}
@GetMapping("/api/refresh")
public ResponseEntity<TokenResponseDto> reissue(HttpServletRequest request) {
String authorizationHeader = request.getHeader("Authorization");
String refreshToken = authorizationHeader.substring("Bearer ".length());
Authentication authentication = jwtProvider.getAuthentication(refreshToken);
Member member = authService.getMemberByEmail(authentication.getName());
RefreshToken refreshTokenObj = refreshTokenService.getRefreshTokenByMember(member);
if (!refreshTokenObj.getRefreshToken().equals(refreshToken)) {
throw new RuntimeException("refresh token 이 다름");
}
String newAccessToken = jwtProvider.createAccessToken(authentication);
TokenResponseDto tokenResponseDto = new TokenResponseDto();
tokenResponseDto.setAccessToken(newAccessToken);
return ResponseEntity.ok(tokenResponseDto);
}
@GetMapping("/api/my")
public ResponseEntity<String> getMy() {
return ResponseEntity.ok("mymy");
}
}
GET /api/refresh 는 SecurityConfig 에서 authenticate() 처리되어, 토큰을 사용해 인증되어야 사용할 수 있습니다.
GET /api/my 는 SecurityConfig 에서 hasRole("MEMBER") 처리되어, ROLE_MEMBER 의 권한을 가진 토큰을 사용해 인증을 해야 사용할 수 있습니다. 추가로 ""ROLE_ADMIN" 도 ROLE_MEMBER" 의 권한을 가지므로 해당 api 를 사용할 수 있습니다.
AdminController
@Controller
@RequiredArgsConstructor
public class AdminController {
private final AuthService authService;
private final PasswordEncoder passwordEncoder;
@GetMapping("/api/admin/my")
public ResponseEntity<String> getAdminMy() {
return ResponseEntity.ok("admin mymy");
}
@PostMapping("/api/admin/signup")
public ResponseEntity<Void> signup(@RequestBody SignupRequestDto signupRequestDto) {
if (!authService.memberValidCheck(signupRequestDto.getEmail())) {
throw new RuntimeException("멤버 있음");
}
Member member = Member.builder()
.name(signupRequestDto.getName())
.sex(signupRequestDto.getSex())
.birthday(signupRequestDto.getBirthday())
.email(signupRequestDto.getEmail())
.password(passwordEncoder.encode(signupRequestDto.getPassword()))
.role(MemberRole.ADMIN)
.authProvider(AuthProvider.EMAIL)
.build();
Long memberId = authService.save(member);
return ResponseEntity.created(URI.create("/api/admin/members/" + memberId)).build();
}
}
GET /api/admin/my 는 SecurityConfig 에서 hasRole('ADMIN') 처리되어, ROLE_ADMIN 권한을 가진 토큰을 사용해 인증해야 사용할 수 있습니다. ROLE_MEMBER 권한을 가진 토큰을 사용하여 인증하면 403 error 를 response 하게 됩니다.
이렇게 Spring Security 와 JWT 를 사용한 간단한 프로젝트를 마무리 하였습니다. 프로젝트를 진행하며 몇 가지 고칠 부분이 있습니다.
먼저, Exception 관련 처리입니다. 원래 RuntimeException 들이 아닌 상황에 맞는 Exception 을 생성하고, 그 Exception 들을 @ExceptionHandler 등을 사용해 처리했어야 했는데 Spring Security 와 JWT 에 초점을 맞추다 보니 간단하게 구현했습니다.
다음 JwfFilter 입니다. 현재 프로젝트에서는 인증이 필요할 때는 JwtFilter 에서 error 를 response 하거나, 인증을 거쳐Authentication 을 생성해 SecurityContext 에 저장합니다. 따라서 AuthenticationFilter 인 UsernamePasswordAuthenticationFilter 는 사실상 동작하지 않습니다. 그렇다면 JwtFilter 에서는 예외만 처리하고, UsernamePasswordAuthenticationFilter 를 상속한 Filter 를 만들어 그곳에서 인증절차를 진행하는 것이 더 좋은 방법일지도 모른다는 생각이 듭니다.
참조
https://docs.spring.io/spring-security/reference/5.6/index.html
https://hungseong.tistory.com/67
https://onejunu.tistory.com/137
'JAVA > Spring' 카테고리의 다른 글
[Spring Security] JWT + OAuth2.0 적용해보기 - (2) (0) | 2023.02.02 |
---|---|
[Spring] @RequestParam vs @ModelAttribute vs @RequestBody (0) | 2022.08.01 |
댓글