<이전글 - OAuth 기본 개념>
1. 스프링 시큐리티로 OAuth2를 구현하고 적용하기
[쿠키 관리 클래스 구현]
↓
[OAuth2에서 제공받은 인증 객체로 사용자 정보를 가져오는 역할은 하는 서비스 구현]
↓
[OAuth2 설정 파일 구현]
1-1. 의존성 추가하기
dependencies {
--- 생략 ---
// OAuth2를 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
OAuth2를 사용하기 위해 build.gradle 파일에 의존성 추가
1-2. 쿠키 관리 클래스 구현하기
OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데,
이때 쿠키를 생성하고 삭제하는 로직을 따로 구현하기
CookieUtil.java
public class CookieUtil {
// 요청값(이름, 값, 만료시간)을 바탕으로 쿠키 추가
public static void addCookie(HttpServletResponse response, String name, String value, int MaxAge){
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(MaxAge);
response.addCookie(cookie);
}
// 쿠키의 이름을 입력받아 쿠키 삭제
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name){
Cookie[] cookies = request.getCookies();
if(cookies == null){
return;
}
for (Cookie cookie : cookies){
if(name.equals(cookie.getName())){
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
// 객체를 직렬화해 쿠키의 값으로 변환
public static String serialize(Object obj){
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
// 쿠키를 역직렬화해 객체로 변환
public static <T> T deserialize(Cookie cookie, Class<T> cls){
return cls.cast(
SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())
)
);
}
}
- addCookie
요청값(이름, 값, 만료기간)을 바탕으로 HTTP 응답에 쿠키를 추가한다. - deleteCookie
쿠키 이름을 입력받아 쿠키를 삭제한다.
실제로 삭제하는 방법은 없으므로 파라미터로 넘어온 값들을 초기화한다. - serialize
객체를 직렬화해 쿠키의 값으로 들어갈 값으로 변환한다. - deserialize
쿠키를 역직렬화 객체로 변환한다.
1-3. OAuth2 서비스 구현하기
사용자 정보를 조회해 users 테이블에
- 사용자 정보가 있다면
리소스 서버에서 제공해 주는 이름을 업데이트
- 사용자 정보가 없다면
uers 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현
◆ domain/User.java
// 사용자 이름
@Column(name="nickname", unique = true)
private String nickname;
// 생성자에 nickname 추가
@Builder
public User(String email, String password, String nickname){
this.email = email;
this.password = password;
this.nickname = nickname;
}
// 사용자 이름 변경
public User update(String nickname){
this.nickname = nickname;
return this;
}
User.java 파일에 사용자 이름과 OAuth 관련 키를 저장하는 코드를 추가한다.
◆ config/oauth/OAuth2UserCustomService.java
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws
OAuth2AuthenticationException{
// 요청을 바탕으로 유저 정보를 담은 객체 반환
OAuth2User user = super.loadUser(userRequest);
saveOrUpdate(user);
return user;
}
// 유저가 있으면 업데이트, 없으면 유저 생성
private User saveOrUpdate(OAuth2User oAuth2User){
Map<String, Object> attributes = oAuth2User.getAttributes();
String email = (String) attributes.get("email");
String name = (String) attributes.get("name");
User user = userRepository.findByEmail(email)
.map(entity -> entity.update(name))
.orElse(User.builder()
.email(email)
.nickname(name)
.build());
return userRepository.save(user);
}
}
- DefaultOAuth2UserService
해당 부모 클래스에서 제공하는 OAuth 서비스에서 제공하는 정보를 기반으로 유저 객체를 만들어주는
loadUser() 메서드를 사용해 사용자 객체를 불러온다. - loadUser()
리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드
사용자를 조회하고, users 테이블에 사용자 정보가 있다면 업데이트
없다면 saveOrUpdate() 메서드 실행하여 users 테이블에 회원 데이터 추가
1-4. OAuth2 설정 파일 작성하기
OAuth2와 JWT를 함께 사용하기 위한 설정
1단계 : WebSecurityConfig.java 모두 주석 처리
2단계: config/WebOAuthSecurityConfig.java
package me.jhzlo.springbootdeveloper.config;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import me.jhzlo.springbootdeveloper.config.jwt.TokenProvider;
import me.jhzlo.springbootdeveloper.config.oauth.OAuth2UserCustomService;
import me.jhzlo.springbootdeveloper.repository.RefreshTokenRepository;
import me.jhzlo.springbootdeveloper.service.UserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import static org.springframework.boot.autoconfigure.security.servlet.PathRequest.toH2Console;
@RequiredArgsConstructor
@Configuration
public class WebOAuthSecurityConfig {
private final OAuth2UserCustomService oAuth2UserCustomService;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final UserService userService;
@Bean
public WebSecurityCustomizer configure(){ // 스프링 시큐리티 기능 비활성화
return (web) -> web.ignoring()
.requestMatchers(toH2Console())
.requestMatchers("/img/**", "/css/**", "/js/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// 1. 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼로그인, 세션 비활성화
http.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable();
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 2. 헤더를 확인할 커스텀 필터 추가
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 3. 토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요
http.authorizeRequests()
.requestMatchers("/api/token").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll();
http.oauth2Login()
.loginPage("/login")
.authorizationEndpoint()
// 4. Authorization 요청과 관련된 상태 저장
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
.and()
.successHandler(oAuth2SuccessHandler()) // 5. 인증 성공 시 실행할 핸들러
.userInfoEndpoint()
.userService(oAuth2UserCustomService);
http.logout()
.logoutSuccessUrl("/login");
// 6. /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리
http.exceptionHandling()
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**"));
return http.build();
}
@Bean
public OAuth2SuccessHandler oAuth2SuccessHandler(){
return new OAuth2SuccessHandler(tokenProvider,
refreshTokenRepository,
oAuth2AuthorizationRequestBasedOnCookieRepository(),
userService
);
}
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter(){
return new TokenAuthenticationFilter(tokenProvider);
}
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository(){
return new Oauth2AuthorizationRequestBasedOnCookieRepository();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
- filterChain() 메서드
토큰 방식으로 인증을 하므로 기존 폼 로그인, 세션 기능을 비활성화한다. - addFilterBefore() 헤더값 확인용 커스텀 필터 추가
헤더값을 확인할 커스텀 필터를 추가한다.
해당 필터는 TokenAuthenAuthenticationFilter 클래스이다. - authorizationRequests() 메서드 URL 인증 설정
토큰 재발급 URL은 인증 없이 접근하도록 설정하고 나머지 API들은 모두 인증을 해야 접근 가능하도록 설정 - 5. oauth2Login() 메서드 이후 체인 메서드 수정
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소 설정
인증 성공 시 실행할 핸들러 설정
5. exceptionHandling() 메서드 예외 처리 설정
/api로 시작하는 url인 경우 인증 실패 시 401 상태 코드 즉 Unauthorized를 반환한다.
3단계: config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepoistory.java
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
private final static int COOKIE_EXPIRE_SECONDS = 18000;
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response){
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request){
Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response){
if(authorizationRequest == null){
removeAuthorizationRequestCookies(request, response);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response){
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소 구현
권한 인증 흐름에서 클라이언트 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현해
쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직이다.
4단계: service/UserService.java
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public Long save(AddUserRequest dto){
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
return userRepository.save(User.builder()
.email(dto.getEmail())
// 패스워드 암호화
.password(encoder.encode(dto.getPassword()))
.build()).getId();
}
public User findById(Long userId){
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
public User findByEmail(String email){
return userRepository.findByEmail(email)
.orElseThrow(()-> new IllegalArgumentException("Unexpected user"));
}
}
인증 성공 시 실행할 핸들러 구현
- 기존의 BCryptPasswordEncoder를 삭제하고 BCryptPasswordEncoder를 생성자를 사용해
직접 생성해서 패스워드를 암호화할 수 있게 코드를 수정 - findByEmail() 메서드 추가
이메일을 입력받아 users 테이블에서 유저를 찾고, 없으면 예외 발생
5단계: config/oauth/OAuth2SuccessHandler.java
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
public static final String REDIRECT_PATH = "/articles";
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
private final UserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
// 1. 리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// 2. 액세스 토큰 생성 -> 패스에 액세스 토큰 추가
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 3. 인증 관련 설정값, 쿠키 제거
clearAuthenticationAttributes(request, response);
// 4. 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 생성된 리프레시 토큰을 전달받아 데이터베이스에 저장
private void saveRefreshToken(Long userId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
.map(entity -> entity.update(newRefreshToken))
.orElse(new RefreshToken(userId, newRefreshToken));
refreshTokenRepository.save(refreshToken);
}
// 생성된 리프레시 토큰을 쿠키에 저장
private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
}
// 인증 관련 설정값, 쿠키 제거
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
// 액세스 토큰을 패스에 추기
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.toUriString();
}
}
스프링 시큐리티의 기본 로직에서는 별도의 authenticationSuccessHandler를 지정하지 않으면 로그인 성공 이후 SimpleUrlAuthenticationSuccessHandler를 사용한다.
일반적인 로직은 동일하게 사용하고, 토큰과 관련된 작업만 추가로 처리하기 위해 SimpleUrlAuthenticationSuccessHandler을 상속받은 뒤에 onAuthenticationSuccess() 메서드를 오버라이드 한다.
- 리프레시 토큰 생성, 저장, 쿠키에 저장
토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에,
saveRefresthToken() 메서드를 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장한다.
그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록
addRefreshTokenToCooke() 메서드를 호출해 쿠키에 리프레시 토큰을 저장한다. - 액세스 토큰 생성, 패스에 액세스 토큰 추가
토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와
쿼리 파라미터에 액세스 토큰을 추가한다.
▼액세스 토큰을 클라이언트에게 전달하는 예시
http://localhost:8080/articles?token=eyJ0eXAiOiJKV1QiLCJhbFCiOiJIUzI1NiJ9.eyJpc3M.... - 인증 관련 설정값, 쿠키 제거
인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해 둔 인증 관련 데이터를 제거한다.
기본적으로 제공하는 메서드인 clearAuthenticationAttributes()는 그대로 호출하고
removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제한다. - 리다이렉트
2에서 만든 URL로 리다이렉트 한다.
>> 여기까지의 과정을 거치면, OAuth를 위한 로직은 모두 완성이 된다.
<다음글 - OAuth 뷰 구현하기, 글에 글쓴이 추가>
'JAVA > SpringBoot 3' 카테고리의 다른 글
[스프링 부트 3] OAuth2 - 3 (OAuth2 뷰 구성하기, 글에 글쓴이 추가) (2) | 2024.08.28 |
---|---|
[스프링 부트 3] OAuth2 - 1 (OAuth란?, 권한 부여 코드 타입 승인의 흐름, 쿠키란?, 토큰 발급받기) (0) | 2024.08.23 |
[스프링 부트 3] JWT - 3 (토큰 필터, 리프레시 토큰 API 구현하기, 로그인/로그아웃) (0) | 2024.08.22 |
[스프링 부트 3] JWT - 2 (JWT 서비스, 토큰 생성자 구현, 로그인/로그아웃) (0) | 2024.08.22 |
[스프링 부트 3] JWT - 1 (토큰 기반 인증, JWT, 액세스 토큰, 리프레시 토큰) (0) | 2024.08.18 |