티스토리 뷰
1. 의존성 추가하기
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
2. 쿠키 관리 클래스 구현하기
- 앞으로 쿠키를 사용할 일이 자주 생기는데, 유틸리티로 사용할 쿠키 관리 클래스를 미리 구현해 둔다.
util.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("/");
// 쿠키의 만료 기간을 설정. maxAge는 초 단위.
cookie.setMaxAge(maxAge);
// HttpServletResponse를 사용하여 쿠키를 응답에 추가.
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("/");
// 쿠키의 만료 기간을 0으로 설정하여
// 해당 쿠키를 즉시 만료시킨다.
cookie.setMaxAge(0);
// HttpServletResponse를 사용하여
// 수정된 쿠키를 응답에 추가하여 삭제한다.
response.addCookie(cookie);
}
}
}
// 객체를 직렬화해 쿠키의 값으로 변환
public static String serialize(Object obj) {
// 객체를 직렬화하고 Base64로 인코딩하여 문자열로 반환한다.
return Base64
.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
// 쿠키를 역직렬화해 객체로 변환
public static <T> T deserialize(Cookie cookie, Class<T> tClass) {
// 쿠키의 값을 Base64로 디코딩하고,
// 역직렬화하여 지정된 클래스로 변환한다.
return tClass.cast(SerializationUtils
.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))
);
}
}
# addCookie
- 요청값(이름, 값, 만료 기간)을 바탕으로 HTTP 응답에 쿠키를 추가한다.
# deleteCookie
- 쿠키 이름을 입력받아 쿠키를 삭제한다.
- 실제 삭제 방법 x. 만료 타이머 설정
# serialize
- 객체를 직렬화해 쿠키의 값으로 들어갈 값 변환
# deserialize
- 쿠키를 역직렬화 객체로 변환한다.
3. OAuth2 서비스 구현하기
3-1. User.java에 OAuth 관련 키 저장하는 코드 추가
User.java
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password") // OAuth 쓰면서 nullable = false 속성 삭제
private String password;
// 사용자 이름
@Column(name = "nickname", unique = true)
private String nickname;
@Builder
public User(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname; // 추가
}
@Override // 권한 반환
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
@Override // 고유한 값 - 사용자의 id를 반환
public String getUsername() {
return email;
}
@Override // 사용자의 패스워드를 반환
public String getPassword() {
return password;
}
@Override // 계정 만료 여부 반환
public boolean isAccountNonExpired() { // 만료되었는지 확인하는 로직
return true; // true -> 유효한 상태임
}
@Override // 계정 잠금 여부 반환
public boolean isAccountNonLocked() { //계정이 잠겼는지 확인하는 로직
return true; // 잠기지 않았음
}
@Override // 패스워드의 만료 여부 반환
public boolean isCredentialsNonExpired() { // 패스워드가 만료되었는지 확인하는 로직
return true; // 만료되지 않았음(true)
}
@Override // 계정 사용 가능 여부 반환
public boolean isEnabled() { // 계정이 사용 가능한지 확인하는 로직
return true; // 사용 가능(true)
}
// 추가
// 사용자 이름 변경
public User update(String nickname) {
this.nickname = nickname;
return this;
}
}
3-2. config에 oauth 패키지 만든 후 OAuth2UserCustomService.java 생성 후 메서드 두개 구현
OAuthUserCustomService
// 사용자 정보 조회 및 업데이트 또는 생성
@Service
@RequiredArgsConstructor
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
// 1. 요청을 바탕으로 유저 정보를 담은 객체 반환
OAuth2User oAuth2User = super.loadUser(userRequest);
// OAuth2 유저 정보를 저장 또는 업데이트하고 해당 유저 정보를 반환
saveOrUpdate(oAuth2User);
return oAuth2User;
}
// 2 유저가 있으면 업데이트, 없으면 유저 생성
private User saveOrUpdate(OAuth2User oAuth2User) {
// OAuth2 유저로부터 속성(attribute) 맵을 가져온다.
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);
}
}
4. OAuth2 설정 파일 작성하기
4-1. 시큐리티 컨피그 파일 대대적 수정
기존의 WebSecurityConfig -> WebOAuthSecurityConfig
@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/**");
}
// Spring Security 필터 체인을 정의하는 빈을 생성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 다양한 보안 설정을 비활성화
http.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable();
// 세션 관리 정책 설정
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 토큰 인증 필터 추가
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 요청에 대한 인가 규칙 설정
http.authorizeRequests()
.requestMatchers("/api/token").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll();
// OAuth2 로그인 구성
http.oauth2Login()
.loginPage("/login")
.authorizationEndpoint()
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
.and()
.successHandler(oAuth2SuccessHandler())
.userInfoEndpoint()
.userService(oAuth2UserCustomService);
// 로그아웃 설정
http.logout()
.logoutSuccessUrl("/login");
// 예외 처리 설정
http.exceptionHandling()
.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**"));
return http.build();
}
// OAuth2 로그인 성공 시 호출할 핸들러 빈 생성
@Bean
public OAuth2SuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(tokenProvider,
refreshTokenRepository,
oAuth2AuthorizationRequestBasedOnCookieRepository(),
userService
);
}
// 토큰 인증 필터 빈 생성
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter(tokenProvider);
}
// OAuth2 인증 요청 쿠키 레포지토리 빈 생성
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
// BCryptPasswordEncoder 빈 생성
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
➊ filterChain() 메서드
토큰 방식으로 인증을 하므로 기존 폼 로그인, 세션 기능을 비활성화한다.
➋ addFilterBefore() 헤더값 확인용 커스텀 필터 추가
헤더값을 확인할 커스텀 필터 추가
➌ authorizeRequests() 메서드 URL 인증 설정
토큰 재발급 URL은 인증 없이 접근하도록 설정하고, 나머지 API들은 모두 인증을 해야 접근하도록 설정함
➍, ➎ oauth2Login() 메서드 이후 체인 메서드 수정
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 설정한다. 인증 성공 시 실행할 핸들러도 설정한다. 해당 클래스는 바로 구현할 예정
➏ exceptionHandling() 메서드 예외 처리 설정
/api로 시작하는 url인 경우 인증 실패 시 401 상태 코드를 반환한다.
4-2. OAuth2에 필요한 정보를 세션x, 쿠키에 저장해서 쓸 수 있도록 저장소 구현
OAuth2AuthorizationRequestBasedOnCookieRepository.java
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
// OAuth2 Authorization Request를 저장할 쿠키의 이름
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) {
// Authorization Request를 삭제하고 반환
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
// 저장된 쿠키에서 Authorization 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) {
// Authorization Request가 없으면 쿠키 삭제
removeAuthorizationRequestCookies(request, response);
return;
}
// Authorization Request를 쿠키에 저장
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
// 저장된 Authorization Request 쿠키 삭제
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
4-3. 생성자 사용하여 직접 생성해서 BCryptPasswordEncoder를 패스워드 암호화 할 수 있게 수정
UserService.java - findByEmail() 추가
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public Long save(AddUserRequest dto) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// UserRepository를 통해 새 사용자를 저장.
// - AddUserRequest에서 전달된 이메일과 비밀번호를 사용하여 새 사용자를 생성.
// - 비밀번호는 BCryptPasswordEncoder를 사용하여 안전하게 해싱됨
return userRepository.save(User.builder()
.email(dto.getEmail())
.password(encoder.encode(dto.getPassword()))
.build()).getId(); // 저장된 사용자의 ID를 반환
}
/*
이걸 리펙토링하면
public Long save(AddUserRequest dto) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
User user = userRepository.save(User.builder()
.email(dto.getEmail())
.password(encoder.encode(dto.getPassword())) // 비밀번호 해싱
.build());
return user.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"));
}
}
4-4. 인증 성공 시 실행할 핸들러 구현
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 {
// ❷ OAuth2 인증에 성공한 사용자의 정보를 가져옵니다.
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));
// ❸ 새 리프레시 토큰을 생성하고 저장합니다.
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
// ❹ 리프레시 토큰을 쿠키에 추가합니다.
addRefreshTokenToCookie(request, response, refreshToken);
// ❺ 액세스 토큰을 생성하고 리다이렉션 URL을 가져옵니다.
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// ❻ 인증에 사용된 정보를 지우고 사용자를 리다이렉션합니다.
clearAuthenticationAttributes(request, response);
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);
}
// ❻ 인증 정보와 OAuth2 권한 요청 관련 쿠키를 지웁니다.
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
// ❺ 액세스 토큰을 포함한 리다이렉션 URL을 생성합니다.
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.toUriString();
}
}
- 스프링 시큐리티의 기본 로직에서는 별도의 authenticationSuccessHandler를 지정하지 않으면 로그인 성공 이후
SimpleUrlAuthenticationSuccessHandler를 사용한다.
- 일반적인 로직은 동일하게 사용하고, 토큰과 관련된 작업만 추가로 처리하기 위해
SimpleUrlAuthenticationSuccessHandler을 상속받은 후에 onAuthenticationSuccess() 메서드를 오버라이드 한다.
실행 흐름
➊ 리프레시 토큰 생성, 저장, 쿠키에 저장
- 토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에,
saveRefreshToken() 메서드를 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장한다.
- 그 이후에는 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie() 메서드를 호출해
쿠키에 리프레시 토큰을 저장한다.
➋ 액세스 토큰 생성, 패스에 액세스 토큰 추가
- 토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가한다.
➌ 인증 관련 설정값, 쿠키 제거
- 인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거한다.
- 기본적으로 제공하는 메서드인 clearAuthenticationAttributes()은 그대로 호출하고,
removeAuthorizationRequestCookies()를 추가로 호출해 OAuth 인증을 위해 저장된 정보도 삭제한다.
➍ 리다이렉트
- ➋에서 만든 URL로 리다이렉트한다.
'[개발] - Spring > [개발예제] 시큐리티, JWT 입히기' 카테고리의 다른 글
토큰 API 구현하기 (0) | 2023.10.19 |
---|---|
JWT 서비스 구현하기 (1) | 2023.10.19 |
JWT 소개 (0) | 2023.10.18 |
[사전 지식] 토큰 기반 인증 (0) | 2023.10.18 |
시큐리티 설정, 로그인/로그아웃, 회원가입 구현 (0) | 2023.10.17 |