spring:
security:
oauth2:
client:
registration:
authclient:
provider: authclient
client-id:
client-secret:
authorization-grant-type: authorization_code
redirect-uri: https://{client 도메인}/login/oauth2/code/authclient
scope:
- public
- profile
provider:
authclient:
authorization-uri: https://{resource_server_domain}/oauth/authorize
token-uri: https://{resource_server_domain}/oauth/token
user-info-uri: https://{resource_server_domain}/v2/me
user-name-attribute: login
provider: kakao, google등의 미리 정의된 provider가 아닌 경우 authclient로 명시하였음.
client-id: Resource Server에 등록했을 때 발급된 client-id
client-secret: 마찬가지
authorization-grant-type: Resource OWner가 승인 시 authorization code발급하는 방식으로 지정.
redirect-uri: code를 보내고 엑세스토큰을 발급받은 후 할 행동을 지정할 콜백, spring security에서는 이를 설정 시 자동으로 구현됨.
scope: 자원 범위
authorization-uri: Resource Server에게 registration.authclient의 정보를 포함하여 요청할 uri이에 요청하면 resource owner에게 authorize를 요청함.
token-uri: code를 가지고 access token을 발급받을 uri
user-info-uri: access token발급 시 Resource server에게 회원 정보 자원을 요청할 uri
user-name-attribute: 회원 정보중 user-name에 해당하는 정보
// spring security 필터를 스프링 필터체인에 동록
@Configuration
@EnableWebSecurity
//Secured, PrePost 어노테이션 활성화
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, proxyTargetClass = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DefaultOAuth2UserService oAuth2UserService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ObjectMapper objectMapper;
private final CustomAuthorizationFilter customAuthorizationFilter;
private final AuthenticationSuccessHandler authenticationSuccessHandler;
private final AuthenticationFailureHandler authenticationFailureHandler;
@Value("${cors.frontend}")
private String corsFrontend;
@Value("${jwt.access-token-expire}")
private String accessTokenExpire;
@Value("${jwt.refresh-token-expire}")
private String refreshTokenExpire;
@Value("${jwt.secret}")
private String jwtSecret;
@Override
protected void configure(HttpSecurity http) throws Exception {
/*
callback(redirect) URI: /login/oauth2/code/authclient
login URI: /oauth2/authorization/authclient - 설정을 하면 바꿀 수 있을 것 같음.
*/
http.oauth2Login()
.userInfoEndpoint()
.userService(oAuth2UserService)
.and()
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/api/security/logout"))
.logoutSuccessHandler((request, response, authentication) -> {
response.setStatus(HttpServletResponse.SC_OK);
});
}
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private static final String ID_ATTRIBUTE = "id";
private static final String LOGIN_ATTRIBUTE = "login";
private static final String EMAIL_ATTRIBUTE = "email";
private static final String IMAGE_ATTRIBUTE = "image";
private static final String LINK_ATTRIBUTE = "link";
private static final String CREATE_FLAG = "create_flag";
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final UserRoleRepository userRoleRepository;
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
/**
* OAuth2 Code Grant 방식으로 인증을 진행하고, 인증이 완료되고 나서 Resource Server로 부터
* 유저 정보를 받아오면 OAuth2UserRequest에 담겨 있음.
* 해당 유저 정보가 DB에 없으면 회원가입을 진행하고 있으면 로그인을 진행.
* @param userRequest the user request
* @return
* @throws OAuth2AuthenticationException
*/
@Transactional
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Map<String, Object> attributes = super.loadUser(userRequest).getAttributes();
//resource Server로 부터 받아온 정보중 필요한 정보 추출.
String apiId = ((Integer)attributes.get(ID_ATTRIBUTE)).toString();
//takim
String login = (String)attributes.get(LOGIN_ATTRIBUTE);
//takim@student.42seoul.kr
String email = (String) attributes.get(EMAIL_ATTRIBUTE);
String imageUrl = "";
//https://cdn.intra.42.fr/users/09cc1ec9/takim.jpg
if (attributes.get(IMAGE_ATTRIBUTE) instanceof Map) {
imageUrl = (String)((Map)(attributes.get(IMAGE_ATTRIBUTE))).get(LINK_ATTRIBUTE) == null ?
"" : (String)((Map)(attributes.get(IMAGE_ATTRIBUTE))).get(LINK_ATTRIBUTE);
}
HashMap<String, Object> necessaryAttributes = createNecessaryAttributes(apiId, login,
email, imageUrl);
String username = email;
Optional<User> userOptional = userRepository.findByUsername(username);
OAuth2User oAuth2User = signUpOrUpdateUser(login, email, imageUrl, username, userOptional, necessaryAttributes);
return oAuth2User;
}
private HashMap<String, Object> createNecessaryAttributes(String apiId, String login, String email, String imageUrl) {
HashMap<String, Object> necessaryAttributes = new HashMap<>();
necessaryAttributes.put(ID_ATTRIBUTE, apiId);
necessaryAttributes.put(LOGIN_ATTRIBUTE, login);
necessaryAttributes.put(EMAIL_ATTRIBUTE, email);
necessaryAttributes.put("image_url", imageUrl);
return necessaryAttributes;
}
private OAuth2User signUpOrUpdateUser(String login, String email, String imageUrl, String username,
Optional<User> userOptional, Map<String, Object> necessaryAttributes) {
OAuth2User oAuth2User;
User user;
//회원가입, 중복 회원가입 예외 처리 필요할 것으로 보임.
if (userOptional.isEmpty()) {
//회원에 필용한 정보 생성 및 조회
String encodedPassword = passwordEncoder.encode(UUID.randomUUID().toString());
Member member = Member.of(login);
memberRepository.save(member);
Role role = roleRepository.findByValue(RoleEnum.ROLE_USER).orElseThrow(() ->
new EntityNotFoundException(RoleEnum.ROLE_USER + "에 해당하는 Role이 없습니다."));
user = User.of(username, encodedPassword, email, login, imageUrl, member);
UserRole userRole = UserRole.of(role, user);
userRepository.save(user);
userRoleRepository.save(userRole);
necessaryAttributes.put(CREATE_FLAG, true);
//생성해야할 객체 추가로 더 있을 수 있음.
} else{
//회원정보 수정
user = userOptional.get();
// 새로 로그인 시 oauth2 기반 데이터로 변경하지않음.
// user.updateUserBHOAuthIfo(imageUrl);
necessaryAttributes.put(CREATE_FLAG, false);
}
oAuth2User = CustomAuthenticationPrincipal.of(user, necessaryAttributes);
return oAuth2User;
}
}
@Slf4j
@Component
public class RedirectAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Value("${cors.frontend}")
private String corsFrontend;
@Value("${jwt.access-token-expire}")
private String accessTokenExpire;
@Value("${jwt.refresh-token-expire}")
private String refreshTokenExpire;
@Value("${jwt.secret}")
private String jwtSecret;
/**
* oauth 로그인 성공시 JWT Token 생성해서 리다이렉트 응답.
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
CustomAuthenticationPrincipal user = (CustomAuthenticationPrincipal) authentication.getPrincipal();
String referer = corsFrontend;
boolean createFlag = (boolean) (user.getAttributes().get("create_flag"));
Algorithm algorithm = Algorithm.HMAC256(jwtSecret.getBytes());
String accessToken = JWTUtil.createToken(request.getRequestURL().toString(),
user.getUsername(), accessTokenExpire, algorithm, user.getAuthorities().stream()
.map(SimpleGrantedAuthority::getAuthority).collect(Collectors.toList()));
String refreshToken = JWTUtil.createToken(request.getRequestURL().toString(),
user.getUsername(), refreshTokenExpire, algorithm);
ResponseCookie cookie = ResponseCookie.from(JWTUtil.REFRESH_TOKEN, refreshToken)
.httpOnly(true)
.secure(true)
.path("/") // path
.maxAge(Duration.ofDays(15))
.sameSite("None") // sameSite
.build();
response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString());
response.sendRedirect(
referer +
"?access_token=" + accessToken +
"&create_flag=" + createFlag +
"&userId=" + user.getApiId());
}
}
public class JWTUtil {
public static final String REFRESH_TOKEN = "refresh-token";
public static String createToken(String requestUrl, String subject,
String tokenExpire, Algorithm algorithm, Collection<String> authorities) {
return JWT.create()
.withSubject(subject)
.withIssuer(requestUrl)
.withExpiresAt(
new Date(System.currentTimeMillis() + Integer.parseInt(tokenExpire)))
.withClaim("authorities",
new ArrayList<>(authorities))
.sign(algorithm);
}
public static String createToken(String requestUrl, String subject,
String tokenExpire, Algorithm algorithm) {
return JWT.create()
.withSubject(subject)
.withIssuer(requestUrl)
.withExpiresAt(
new Date(System.currentTimeMillis() + Integer.parseInt(tokenExpire)))
.sign(algorithm);
}
/**
* 1. 토큰이 정상적인지 검증(위조, 만료 여부) 2. Access Token인지 Refresh Token인지 구분
*
* @param algorithm
* @param token
* @return
* @throws JWTVerificationException
*/
public static JWTInfo decodeToken(Algorithm algorithm, String token)
throws JWTVerificationException {
JWTVerifier verifier = JWT.require(algorithm).build();
/**
* WT 토큰 검증 실패하면 JWTVerificationException 발생
* Throws:
* AlgorithmMismatchException – if the algorithm stated in the token's header is not equal to the one defined in the JWTVerifier.
* SignatureVerificationException – if the signature is invalid.
* TokenExpiredException – if the token has expired.
* InvalidClaimException – if a claim contained a different value than the expected one.
*/
DecodedJWT decodedJWT = verifier.verify(token);
String username = decodedJWT.getSubject();
String[] authoritiesJWT = null;
authoritiesJWT = decodedJWT.getClaim("authorities")
.asArray(String.class);
if (authoritiesJWT != null) {
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
Arrays.stream(authoritiesJWT).forEach(authority -> {
authorities.add(new SimpleGrantedAuthority(authority));
});
}
return JWTInfo.builder()
.username(username)
.authorities(authoritiesJWT)
.build();
}
@Getter
@Builder
public static class JWTInfo {
private final String username;
private final String[] authorities;
}
}
// spring security 필터를 스프링 필터체인에 동록
@Configuration
@EnableWebSecurity
//Secured, PrePost 어노테이션 활성화
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true, proxyTargetClass = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DefaultOAuth2UserService oAuth2UserService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ObjectMapper objectMapper;
private final CustomAuthorizationFilter customAuthorizationFilter;
private final AuthenticationSuccessHandler authenticationSuccessHandler;
private final AuthenticationFailureHandler authenticationFailureHandler;
@Value("${cors.frontend}")
private String corsFrontend;
@Value("${jwt.access-token-expire}")
private String accessTokenExpire;
@Value("${jwt.refresh-token-expire}")
private String refreshTokenExpire;
@Value("${jwt.secret}")
private String jwtSecret;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.cors().configurationSource(corsConfigurationSource());
http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
http.addFilterBefore(customAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
/**
* 1. OncePerRequestFilter를 활용하는 이유
* Servlet이 다른 Servler을 dispatch하는 경우 FilterChain을 여러번 거치게 되는데
* OnceOErRequestFilter를 사용하는 경우 무조건 한번만 거치게 된다.
* https://stackoverflow.com/questions/13152946/what-is-onceperrequestfilter
*/
@Slf4j
@Component
public class CustomAuthorizationFilter extends OncePerRequestFilter {
@Value("${jwt.secret}")
private String secret;
private static final String BEARER = "Bearer ";
/**
* 인증 시 Authorization header에 Bearer 토큰을 담아서 보내기 때문에
* 이를 추출하여 토큰 검증을 진행한다.
* @param request
* @param response
* @param filterChain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorizationHeader != null && authorizationHeader.startsWith(BEARER)) {
String token = getToken(authorizationHeader);
Algorithm algorithm = Algorithm.HMAC256(secret.getBytes());
JWTInfo jwtInfo = null;
try {
//JWT 토큰 검증 실패하면 JWTVerificationException 발생
jwtInfo = JWTUtil.decodeToken(algorithm, token);
log.debug(jwtInfo.toString());
//access token이 만료된 경우 403과 응답 코드를 전달
} catch(TokenExpiredException tokenExpiredException){
log.debug(tokenExpiredException.getMessage());
final ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.ACCESS_TOKEN_EXPIRED);
setAccessTokenExpiredResponse(response, errorResponse);
return ;
} catch (JWTVerificationException jwtException) {
log.debug("JWT Verification Failure : {}", jwtException.getMessage());
}
/**
* Access Token인 경우 authorities가 존재하므로
* SecurityContextHoler에 정보 저장.
* SpringSecurity 에서 Authentication을 등록하지 않고 Custom Filter를 이용하여 등록해서 인지
* 세션생성을 방지하는 옵션을 사용하였음에도 세션을 생성하여 반환함.
* 만료된 토큰임에도 로그인이 풀리지 않아 세션을 생성하지 않도록 설정하려고 하였으나 실패함.
* https://www.baeldung.com/spring-security-session
* 이미 생성된 세션은 사용하지 않도록 SessionCreationPolicy NEVER->STATELESS로 변경하여 해결.
*/
if (jwtInfo != null && jwtInfo.getAuthorities() != null) {
SecurityContextHolder.getContext()
.setAuthentication(getAuthenticationTokenFromDecodedJwtInfo(jwtInfo));
}
//JWT 토큰이 없는 경우 일단 통과 시킴.
//Security Filter chain에서 인증, 인가 여부가 필요한지에 따라
//요청 처리여부가 결정됨..
}
//access token 만료를 제외 하면 모두 filter chain호출.
filterChain.doFilter(request, response);
}
private void setAccessTokenExpiredResponse(HttpServletResponse response, ErrorResponse errorResponse)
throws IOException {
response.setStatus(errorResponse.getStatus());
Map<String, String> body = new HashMap<>();
body.put("message", errorResponse.getMessage());
body.put("status",Integer.toString(errorResponse.getStatus()));
body.put("code", errorResponse.getCode());
response.setContentType(APPLICATION_JSON_VALUE);
new ObjectMapper().writeValue(response.getOutputStream(), body);
}
/**
* SecurityContextHolder에 Authentication 저장할 Authentication 정보를 저장.
* @param jwtInfo
*/
private UsernamePasswordAuthenticationToken getAuthenticationTokenFromDecodedJwtInfo(JWTInfo jwtInfo) {
return new UsernamePasswordAuthenticationToken(CustomAuthenticationPrincipal.of(
User.of(jwtInfo.getUsername(),
null, null, null, null, null), null),
null,
Arrays.stream(jwtInfo.getAuthorities())
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()));
}
private String getToken(String authorizationHeader) {
return authorizationHeader.substring("Bearer ".length());
}
}
Controller에서 Cookie값 Refresh Token 추출
@Operation(summary = "Access_token 만료시 요청해서 재발급", description = "Access_token 만료시 요청해서 재발급")
@GetMapping("/token/refresh")
public AccessTokenResponse getAccessTokenUsingRefreshToken(HttpServletRequest request,
HttpServletResponse response) {
if (request.getCookies() == null){
throw new InvalidInputException(ErrorCode.REFRESH_TOKEN_NOT_IN_COOKIE);
}
String refreshToken = Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(JWTUtil.REFRESH_TOKEN))
.map(Cookie::getValue)
.findFirst().orElseThrow(() ->
new InvalidInputException(ErrorCode.REFRESH_TOKEN_NOT_IN_COOKIE));
log.debug(refreshToken);
return userService.validateRefreshTokenAndCreateAccessToken(refreshToken, request.getRequestURL().toString());
}
public AccessTokenResponse validateRefreshTokenAndCreateAccessToken(String refreshToken, String issuer) {
Algorithm algorithm = Algorithm.HMAC256(jwtSecret.getBytes());
JWTInfo jwtInfo = null;
try {
//JWT 토큰 검증 실패하면 JWTVerificationException 발생
jwtInfo = JWTUtil.decodeToken(algorithm, refreshToken);
log.debug(jwtInfo.toString());
//refresh token 이 만료되었거나 적절하지 않은 경우 401
} catch (JWTVerificationException jwtException) {
log.debug("JWT Verification Failure : {}", jwtException.getMessage());
throw new InvalidInputException(ErrorCode.INVALID_REFRESH_TOKEN);
}
User user = getUserFromJWTInfo(jwtInfo);
List<String> authorities = user.getUserRoles().stream()
.map(UserRole::getRole)
.map(Role::getAuthorities)
.flatMap(Set::stream)
.map(Authority::getPermission)
.collect(Collectors.toList());
String accessToken = JWTUtil.createToken(issuer, user.getUsername(), accessTokenExpire, algorithm,
authorities);
return AccessTokenResponse.builder()
.accessToken(accessToken)
.build();
}