Spring Security + OAuth2 + JWT
Spring에서 Spring Security와 JWT를 사용한 소셜 로그인(OAuth2)을 구현하겠습니다.
소셜 로그인으로는 Google, Github 2개를 연결하겠습니다.
이번 예제에서는 ID/PW방식은 도입하지 않고 오직 소셜로그인만 지원합니다.
프로젝트 구조
설정
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
application.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: "749103847510-gbbje4dhnm902l42hfnvl3rjqokocs8q.apps.googleusercontent.com"
client-secret: "MUSODP-tzCziqenf3yR4UnPgcj3-xYHcO1J"
redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
scope: profile, email
github:
client-id: "e3023ac7cf83048a3dc2"
client-secret: "e0cf2729b5fa5tf13d4a7fa0d661b0911f016495"
redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
scope: user
app:
auth:
token:
secret-key: "6B64DCA4E72F58EDIKU9AAB215FE7"
refresh-cookie-key: "refresh"
oauth2:
authorizedRedirectUri: "http://localhost:3000/oauth2/redirect"
- OAuth2의 기본
redirectUri
는/login/oauth2/code/{provider}
입니다- ex)
/login/oauth2/code/google
- ex)
- 기본 redirectUri를 그대로 사용할 때는 특별한 설정이 필요 없지만 변경하고 싶으면
application.yml
에 명시해야 합니다
SecurityConfig
@Configuration
@EnableWebSecurity
@Log4j2
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
private final OAuth2AuthenticationSuccessHandler authenticationSuccessHandler;
private final OAuth2AuthenticationFailureHandler authenticationFailureHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/h2-console/**", "/favicon.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/h2-console/**").permitAll()
.antMatchers("/oauth2/**", "/auth/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
http.cors() // CORS on
.and()
.csrf().disable() // CSRF off
.httpBasic().disable() // Basic Auth off
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // Session off
http.formLogin().disable()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthorizationRequestRepository)
.and()
.redirectionEndpoint()
.baseUri("/oauth2/callback/*")
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService)
.and()
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
http.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler); // 403
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
-
oauth2Login()
-
authorizationEndpoint
: 프론트엔드에서 백엔드로 소셜로그인 요청을 보내는 URI입니다- 기본 URI는
/oauth2/authorization/{provider}
입니다. ex) /oauth2/authorization/google - URI를 변경하고 싶으면
baseUri(uri)
를 사용하여 설정합니다. - 위에 같이 설정하면
/oauth2/authorize/{provider}
가 됩니다. ex) /oauth2/authorize/google -
authorizationRequestRepository
: Authorization 과정에서 기본으로 Session을 사용하지만 Cookie로 변경하기 위해 설정합니다
- 기본 URI는
-
redirectionEndpoint
: Authorization 과정이 끝나면Authorization Code
와 함께 리다이렉트할 URI입니다- 기본 URI는
/login/oauth2/code/{provider}
입니다. ex) /login/oauth2/code/google - URI를 변경하고 싶으면 마찬가지로
baseUri(uri)
를 사용하여 설정합니다. - 위에 같이 설정하면
/oauth2/callback/{provider}
가 됩니다. ex) /oauth2/callback/google
- 기본 URI는
-
userInfoEndPoint
: Provider로부터 획득한 유저정보를 다룰 service class를 지정합니다 -
successHandler
: OAuth2 로그인 성공시 호출할 handler -
failureHandler
: OAuth2 로그인 실패시 호출할 handler
-
-
exceptionHandling()
: JWT를 다룰 때 생길 excepion을 처리할 class를 지정합니다-
authenticationEntryPoint
: 인증 과정에서 생길 exception을 처리 -
accessDeniedHandler
: 인가 과정에서 생길 exception을 처리
-
-
addFilterBefore
: 모든 request에서 JWT를 검사할 filter를 추가합니다-
UsernamePasswordAuthenticationFilter
에서 클라이언트가 요청한 리소스의 접근권한이 없을 때 막는 역할을 하기 때문에 이 필터 전에jwtAuthenticationFilter
를 실행
-
WebConfig
-
SecurityConfig
에서 CORS를 허용했기 때문에 관련 설정을 추가합니다 -
http://localhost:3000
으로 부터의 요청을 허용한다
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(MAX_AGE_SECS);
}
}
USER
User
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "user_table")
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(unique = true)
private String name;
private String description;
private String img;
@Enumerated(EnumType.STRING)
private UserRole role;
@Enumerated(EnumType.STRING)
private AuthProvider authProvider;
private String githubUrl;
private String blogUrl;
private String refreshToken;
}
UserRole
@Getter
@RequiredArgsConstructor
public enum UserRole {
ADMIN("ROLE_ADMIN", "admin"),
USER("ROLE_USER", "user");
private final String role;
private final String name;
}
AuthProvider
@Getter
public enum AuthProvider {
GOOGLE, GITHUB
}
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u.refreshToken FROM User u WHERE u.id=:id")
String getRefreshTokenById(@Param("id") Long id);
@Transactional
@Modifying
@Query("UPDATE User u SET u.refreshToken=:token WHERE u.id=:id")
void updateRefreshToken(@Param("id") Long id, @Param("token") String token);
}
OAuth2 + JWT flow
OAuth2
- OAuth2UserInfo Mapping 과정
- Provider 마다 다른 정보를 return하기 때문에 이를 다르게 맵핑해야 한다
OAuth2UserInfo
@Getter
@Setter
@ToString
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}
GoogleOAuth2UserInfo
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
GithubOAuth2UserInfo
public class GithubOAuth2UserInfo extends OAuth2UserInfo {
public GithubOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return ((Integer) attributes.get("id")).toString();
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("avatar_url");
}
}
OAuth2UserInfoFactory
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map<String, Object> attributes) {
switch (authProvider) {
case GOOGLE: return new GoogleOAuth2UserInfo(attributes);
case GITHUB: return new GithubOAuth2UserInfo(attributes);
default: throw new IllegalArgumentException("Invalid Provider Type.");
}
}
}
CookieAuthorizationRequestRepository
- Provider와의 Authorization 과정에서
Authorization request
를 cookie에 저장하기 위한 클래스 -
oauth2_auth_request
쿠키 : 해당 Authorizaion request의 고유 아이디를 담는다 -
redirect_uri
쿠키 : 해당 Authorization request시 파라미터로 넘어온 redirect_uri를 담는다. 이 쿠키는 나중에applcation.yml
의authorizedRedirectUri
와 일치하는지 확인시 사용된다http://localhost:8080/oauth2/authorize/google?redirect_uri=http://localhost:3000/oauth2/redirect
- 인증 요청시 생성된 2개의 쿠키는 인증이 종료될 때 실행되는
successHandler
와failureHandler
에서 제거된다 - 2개의 쿠키 유효시간은 180초로 유효시간 내에 인증요청을 다시하면 만들어졌던 쿠키를 다시 사용한다
@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int COOKIE_EXPIRE_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
CustomOAuth2UserService
-
loadUser()
를 오버라이드해서OAuth2UserRequest
에 있는Access Token
으로 유저정보를 얻는다 - 획득한 유저정보를
process()
에서 Java Model과 맵핑하고 가입 되지 않은 경우와 이미 가입된 경우를 구분하여 프로세스르 진행한다 - 결과로
OAuth2User
를 구현한CustomUserDetails
객체를 생성한다
@Log4j2
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
// OAuth2UserRequest에 있는 Access Token으로 유저정보 get
@Override
public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);
return process(oAuth2UserRequest, oAuth2User);
}
// 획득한 유저정보를 Java Model과 맵핑하고 프로세스 진행
private OAuth2User process(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
AuthProvider authProvider = AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, oAuth2User.getAttributes());
if (userInfo.getEmail().isEmpty()) {
throw new OAuthProcessingException("Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository.findByEmail(userInfo.getEmail());
User user;
if (userOptional.isPresent()) { // 이미 가입된 경우
user = userOptional.get();
if (authProvider != user.getAuthProvider()) {
throw new OAuthProcessingException("Wrong Match Auth Provider");
}
} else { // 가입되지 않은 경우
user = createUser(userInfo, authProvider);
}
return CustomUserDetails.create(user, oAuth2User.getAttributes());
}
private User createUser(OAuth2UserInfo userInfo, AuthProvider authProvider) {
User user = User.builder()
.email(userInfo.getEmail())
.img(userInfo.getImageUrl())
.role(UserRole.USER)
.state(UserState.ACT)
.authProvider(authProvider)
.build();
return userRepository.save(user);
}
}
CustomUserDetails
-
Authentication
객체를 커스텀한 클래스이다 - 필드로는 필요하다고 생각되는 id와 email만 생성했다
@Getter
public class CustomUserDetails implements UserDetails, OAuth2User {
private Long id;
private String email;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public CustomUserDetails(Long id, String email, Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.authorities = authorities;
}
public static CustomUserDetails create(User user) {
List<GrantedAuthority> authorities = Collections.
singletonList(new SimpleGrantedAuthority("ROLE_USER"));
return new CustomUserDetails(
user.getId(),
user.getEmail(),
authorities
);
}
public static CustomUserDetails create(User user, Map<String, Object> attributes) {
CustomUserDetails userDetails = CustomUserDetails.create(user);
userDetails.setAttributes(attributes);
return userDetails;
}
// UserDetail Override
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return null;
}
@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 String getName() {
return String.valueOf(id);
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
}
OAuth2AuthenticationFailureHandler
- OAuth2 로그인 실패시 호출되는 Handler
- 인증요청시 생성된 쿠키들을 삭제하고 error를 담아 보낸다
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final CookieAuthorizationRequestRepository authorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse("/");
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
OAuth2AuthenticationSuccessHandler
- OAuth2 로그인 성공시 호출되는 Handler
- 로그인에 성공하면 JWT를 생성한 다음
authorizedRedirectUri
로 client에게 전송한다
@Log4j2
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorizedRedirectUri}")
private String redirectUri;
private final JwtTokenProvider tokenProvider;
private final CookieAuthorizationRequestRepository authorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
log.debug("Response has already been committed");
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("redirect URIs are not matched");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
// JWT 생성
String accessToken = tokenProvider.createAccessToken(authentication);
tokenProvider.createRefreshToken(authentication, response);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("accessToken", accessToken)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
URI authorizedUri = URI.create(redirectUri);
if (authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedUri.getPort() == clientRedirectUri.getPort()) {
return true;
}
return false;
}
}
JWT
JwtTokenProvider
- JWT 토큰을 생성하는 클래스
- Access Token : LocalStorage / Refresh Token : Cookie(http only secure)에 저장한다
- Refresh Token은 DB에 저장하여 갱신시 일치여부 판단을 하는데 사용한다
@Log4j2
@Component
public class JwtTokenProvider {
private final String SECRET_KEY;
private final String COOKIE_REFRESH_TOKEN_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";
@Autowired
private UserRepository userRepository;
public JwtTokenProvider(@Value("${app.auth.token.secret-key}")String secretKey, @Value("${app.auth.token.refresh-cookie-key}")String cookieKey) {
this.SECRET_KEY = Base64.getEncoder().encodeToString(secretKey.getBytes());
this.COOKIE_REFRESH_TOKEN_KEY = cookieKey;
}
public String createAccessToken(Authentication authentication) {
Date now = new Date();
Date validity = new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_LENGTH);
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)
.setIssuer("debrains")
.setIssuedAt(now)
.setExpiration(validity)
.compact();
}
public void createRefreshToken(Authentication authentication, HttpServletResponse response) {
Date now = new Date();
Date validity = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_LENGTH);
String refreshToken = Jwts.builder()
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.setIssuer("debrains")
.setIssuedAt(now)
.setExpiration(validity)
.compact();
saveRefreshToken(authentication, refreshToken);
ResponseCookie cookie = ResponseCookie.from(COOKIE_REFRESH_TOKEN_KEY, refreshToken)
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(REFRESH_TOKEN_EXPIRE_LENGTH/1000)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
private void saveRefreshToken(Authentication authentication, String refreshToken) {
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
Long id = Long.valueOf(user.getName());
userRepository.updateRefreshToken(id, refreshToken);
}
// Access Token을 검사하고 얻은 정보로 Authentication 객체 생성
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
CustomUserDetails principal = new CustomUserDetails(Long.valueOf(claims.getSubject()), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public Boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
log.info("JWT 토큰이 잘못되었습니다");
}
return false;
}
// Access Token 만료시 갱신때 사용할 정보를 얻기 위해 Claim 리턴
private Claims parseClaims(String accessToken) {
try {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtAuthenticationFilter
- 모든 Request에서 JWT를 검사하는 필터
- Http Request header에
Authorization : Bearer <JWT>
형태로 전송된 Access Token을 검사하고 유효한다면 TokenProvider에서 생성된 Authentication 객체를SecurityContext
에 저장한다 - 여기서
SecurityContext
에 저장된 Authentication 정보는 Controller에서@AuthenticationPrincipal
로 꺼내 사용가능하다 -
tokenProvider.getAuthentication()
에서 제공된 class타입과@AuthenticationPrincipal
에서 사용하는 class 타입이 일치해야 한다
@Log4j2
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = parseBearerToken(request);
// Validation Access Token
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug(authentication.getName() + "의 인증정보 저장");
} else {
log.debug("유효한 JWT 토큰이 없습니다.");
}
filterChain.doFilter(request, response);
}
private String parseBearerToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
JwtAuthenticationEntryPoint
- 사용자가 인증없이 요청시
401 Unauthorized
처리(인증)
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getLocalizedMessage());
}
}
JwtAccessDeniedHandler
- 사용자가 권한없는 요청시
403 Forbidden
처리(인가)
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
ETC
OAuthProcessingException
public class OAuthProcessingException extends RuntimeException {
public OAuthProcessingException(String message) {
super(message);
}
}
BadRequestException
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}
CookieUtil
- 쿠키를 생성, 제거, 직렬화, 역직렬화 하는 클래스
public class CookieUtil {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie: cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
Test
1) 로그인 유저 정보 가져오기
UserController
@Log4j2
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/me")
@PreAuthorize("hasRole('USER')")
public User getCurrentUser(@AuthenticationPrincipal CustomUserDetails user) {
return userRepository.findById(user.getId()).orElseThrow(() -> new IllegalStateException("not found user"));
}
}
2) Refresh Token을 사용하여 JWT 갱신
AuthController
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
@PostMapping("/refresh")
public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response, @RequestBody String accessToken) {
return ResponseEntity.ok().body(authService.refreshToken(request, response, accessToken));
}
}
AuthService
@Log4j2
@Service
@RequiredArgsConstructor
public class AuthService {
@Value("${app.auth.token.refresh-cookie-key}")
private String cookieKey;
private final UserRepository userRepository;
private final JwtTokenProvider tokenProvider;
public String refreshToken(HttpServletRequest request, HttpServletResponse response, String oldAccessToken) {
// 1. Validation Refresh Token
String oldRefreshToken = CookieUtil.getCookie(request, cookieKey)
.map(Cookie::getValue).orElseThrow(() -> new RuntimeException("no Refresh Token Cookie"));
if (!tokenProvider.validateToken(oldRefreshToken)) {
throw new RuntimeException("Not Validated Refresh Token");
}
// 2. 유저정보 얻기
Authentication authentication = tokenProvider.getAuthentication(oldAccessToken);
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
Long id = Long.valueOf(user.getName());
// 3. Match Refresh Token
String savedToken = userRepository.getRefreshTokenById(id);
if (!savedToken.equals(oldRefreshToken)) {
throw new RuntimeException("Not Matched Refresh Token");
}
// 4. JWT 갱신
String accessToken = tokenProvider.createAccessToken(authentication);
tokenProvider.createRefreshToken(authentication, response);
return accessToken;
}
}
<참고자료>
https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/
https://bcp0109.tistory.com/301
https://minholee93.tistory.com/entry/Spring-Security-Google-Login-with-Spring-Security-JWT-1
http://yoonbumtae.com/?p=3000