10. OAuth2로 로그인/로그아웃
10.1 OAuth
OAuth는 제3의 서비스에 계정 관리를 맡기는 방식입니다.
OAuth 용어
리소스 오너: 인증 서버에 자신의 정보를 사용하도록 허가하는 주체. 서비스를 이용하는 사용자가 리소스 오너에 해당합니다.
리소스 서버: 리소스 오너의 정보를 가지며 리소스 오너의 정보를 보호하는 주체. 네이버, 구글, 페이스북 리소스 서버에 해당합니다.
인증 서버: 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션.
클라이언트 애플리케이션: 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체. 지금 만들고 있는 서비스가 이에 해당합니다.
쿠키: 사용자가 어떤 웹사이트를 방문했을 때 해당 웹사이트의 서버에서 로컬 환경에 저장하는 작은 데이터. 이 값이 있어 방문했던 적이 있는지 알 수 있고 로그인을 했다면 로그인 정보도 유지할 수 있습니다. 키와 값으로 이루어져 있으며 만료기간, 도메인 등의 정보를 갖습니다.
10.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: 쿠키 이름을 입력받아 쿠키를 삭제합니다. 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 만료 처리합니다.
serialize: 객체를 직렬화해 쿠키의 값에 들어갈 값으로 변환합니다.
deserialize: 역직렬화해 객체로 변환합니다.
User.java
@Builder
public User(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname;
}
//사용자 이름
@Column(name="nickname", unique = true)
private String nickname;
//사용자 이름 변경
public User update(String nickname){
this.nickname = nickname;
return this;
}
users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트합니다. 없다면 user 테이블에 새 사용자를 생성해 데이터베이스에 저장합니다.
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);
}
}
리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드인 loadUser()를 통해 사용자를 조회.
user테이블에 사용자 정보가 있다면 업데이트, 없다면 saveOrUpdate()를 통해 users 테이블에 회원 데이터를 추가.
WebOAuthSecurityConfig.java
@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(
new AntPathRequestMatcher("/img/**"),
new AntPathRequestMatcher("/css/**"),
new AntPathRequestMatcher("/js/**")
);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// (1) 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼 로그인, 세션 비활성화
return http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// (2) 헤더를 확인할 커스텀 필터 추가
.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// (3) 토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요
.authorizeRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/api/token")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/**")).authenticated()
.anyRequest().permitAll())
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
// (4) Authorization 요청과 관련된 상태 저장
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(oAuth2UserCustomService))
// (5) 인증 성공 시 실행할 핸들러
.successHandler(oAuth2SuccessHandler())
)
// (6) /api로 시작하는 url인 경우 401 상태 코드를 반환하도록 예외 처리
.exceptionHandling(exceptionHandling -> exceptionHandling
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")
))
.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();
}
}
(1) filterChain()메서드
토큰 방식으로 인증하므로 기존 폼 로그인, 세션 기능 비활성화.
(2) addFilterBefore() 헤더값 확인용 커스텀 필터 추가
헤더값을 확인할 커스텀 필터를 추가합니다.
(3) authorizeRequest() 메서드 URL 인증 설정
토큰 재발급URL은 인증 없이 접근하도록 설정하고 나머지 API들은 모두 인증을 해야 접근하도록 설정.
(4), (5) oauth2Login() 메서드 이후 체인 메서드 수정
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 설정합니다. 인증 성공 시 실행할 핸들러도 설정합니다.
(6) exceptionHandling() 메서드 예외 처리 설정
/api로 시작하는 url 인경우 인증 실패 시401 상태 코드 즉 Unauthozied를 반환합니다.
OAuth@AuthorizationRequestBasedOnCookieRepository.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에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장소입니다.
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"));
}
}
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();
}
}
(1) 리프레시 토큰 생성, 저장, 쿠키에 저장
토큰 제공자를 사용해 리프레시 토큰을 만들고 saveRefreshToken()을 호출해 해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장합니다. 클라이언트에서 액세스 토큰이 만료되면 재발급 요청하도록 addRefreshTokenToCookie()fmf 호출해 쿠키에 리프레시 토큰을 저장합니다.
(2) 액세스 토큰 생성, 패스에 액세스 토큰 추가
토큰 제공자를 사용해 액세스 토큰을 만들고 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰을 추가.
(3) 인증 관련 설정값, 쿠키 제거
인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터를 제거합니다. 기본적으로 제공하는 메서드인 clearAuthenticationAttributes()는 그대로 호출하고 removeAuthorizationRequestCookies()를 추가로 호출해 인증을 위해 저장된 정보도 삭제합니다.
(4) (2)에서 만든 URL로 리다이렉트 합니다.
글에 글쓴이 추가
Article.java
@Column(name="author", nullable = false)
private String author;
@Builder
public Article(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
AddArticleRequest.java
toEntity() author값도 추가 저장
public Article toEntity(String author){
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
BlogService.java
save() 유저 이름을 추가로 입력 받고 toEntitt()의 인수로 전달 받은 유저 이름을 반환
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
BlogApiController.java
현재 인증 정보를 가져오는 principa 객체를 파라미터로 추가.
@RequiredArgsConstructor
@RestController
public class BlogApiController {
private final BlogService blogService;
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
Article savedArticle = blogService.save(request, principal.getName());
//요청한 자원이 성공적으로 생성되었으며 저장된 블로그 글 정보를 응답 객체에 담아 전송
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
ArticleViewResponse.java author 필드를 추가
@NoArgsConstructor
@Getter
public class ArticleViewResponse {
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
private String author;
public ArticleViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
this.createdAt = article.getCreatedAt();
this.author = article.getAuthor();
}
}
OAuth 뷰 구성하기
oauthLogin.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css">
<style>
.gradient-custom {
background: #6a11cb;
background: -webkit-linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1));
background: linear-gradient(to right, rgba(106, 17, 203, 1), rgba(37, 117, 252, 1))
}
</style>
</head>
<body class="gradient-custom">
<section class="d-flex vh-100">
<div class="container-fluid row justify-content-center align-content-center">
<div class="card bg-dark" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
<h2 class="text-white">LOGIN</h2>
<p class="text-white-50 mt-2 mb-5">서비스 사용을 위해 로그인을 해주세요!</p>
<div class = "mb-2">
<a href="/oauth2/authorization/google">
<img src="/img/google.png">
</a>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
html과 연결할
token.js
const token = searchParam('token')
if (token) {
localStorage.setItem("access_token", token)
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
article.js
// 삭제 기능
const deleteButton = document.getElementById('delete-btn');
if (deleteButton) {
deleteButton.addEventListener('click', event => {
let id = document.getElementById('article-id').value;
function success() {
alert('삭제가 완료되었습니다.');
location.replace('/articles');
}
function fail() {
alert('삭제 실패했습니다.');
location.replace('/articles');
}
httpRequest('DELETE',`/api/articles/${id}`, null, success, fail);
});
}
// 수정 기능
const modifyButton = document.getElementById('modify-btn');
if (modifyButton) {
modifyButton.addEventListener('click', event => {
let params = new URLSearchParams(location.search);
let id = params.get('id');
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
})
function success() {
alert('수정 완료되었습니다.');
location.replace(`/articles/${id}`);
}
function fail() {
alert('수정 실패했습니다.');
location.replace(`/articles/${id}`);
}
httpRequest('PUT',`/api/articles/${id}`, body, success, fail);
});
}
// 생성 기능
const createButton = document.getElementById('create-btn');
if (createButton) {
// 등록 버튼을 클릭하면 /api/articles로 요청을 보낸다
createButton.addEventListener('click', event => {
body = JSON.stringify({
title: document.getElementById('title').value,
content: document.getElementById('content').value
});
function success() {
alert('등록 완료되었습니다.');
location.replace('/articles');
};
function fail() {
alert('등록 실패했습니다.');
location.replace('/articles');
};
httpRequest('POST','/api/articles', body, success, fail)
});
}
// 로그아웃 기능
const logoutButton = document.getElementById('logout-btn');
if (logoutButton) {
logoutButton.addEventListener('click', event => {
function success() {
// 로컬 스토리지에 저장된 액세스 토큰을 삭제
localStorage.removeItem('access_token');
// 쿠키에 저장된 리프레시 토큰을 삭제
deleteCookie('refresh_token');
location.replace('/login');
}
function fail() {
alert('로그아웃 실패했습니다.');
}
httpRequest('DELETE','/api/refresh-token', null, success, fail);
});
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(';');
cookie.some(function (item) {
item = item.replace(' ', '');
var dic = item.split('=');
if (key === dic[0]) {
result = dic[1];
return true;
}
});
return result;
}
// 쿠키를 삭제하는 함수
function deleteCookie(name) {
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
// HTTP 요청을 보내는 함수
function httpRequest(method, url, body, success, fail) {
fetch(url, {
method: method,
headers: { // 로컬 스토리지에서 액세스 토큰 값을 가져와 헤더에 추가
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: body,
}).then(response => {
if (response.status === 200 || response.status === 201) {
return success();
}
const refresh_token = getCookie('refresh_token');
if (response.status === 401 && refresh_token) {
fetch('/api/token', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: getCookie('refresh_token'),
}),
})
.then(res => {
if (res.ok) {
return res.json();
}
})
.then(result => { // 재발급이 성공하면 로컬 스토리지값을 새로운 액세스 토큰으로 교체
localStorage.setItem('access_token', result.accessToken);
httpRequest(method, url, body, success, fail);
})
.catch(error => fail());
} else {
return fail();
}
});
}
BlogServie.java
public void delete(long id) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
authorizeArticleAuthor(article);
blogRepository.delete(article);
}
@Transactional
public Article update(long id, UpdateArticleRequest request) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found : " + id));
authorizeArticleAuthor(article);
article.update(request.getTitle(), request.getContent());
return article;
}
private static void authorizeArticleAuthor(Article article) {
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
if (!article.getAuthor().equals(userName)) {
throw new IllegalArgumentException("not authorized");
}
}
이제 글을 수정하거나 삭제할 때 요청 헤더에 토큰을 전달하므로 사용자 자신이 작성한 글인지 아닌지 검증할 수 있습니다. 본인 글이 아닌데 수정이나 삭제를 시도하는 경우에 예외를 발생시킵니다.
10.4 OAuth2 실행 테스트
개발자 도구의 Appication의 LoalStorage를 확인하면 액세스 토큰을 저장하는 것을 확인할 수 있습니다.
Cookies를 눌러 리프레시 토큰도 잘 저장되어 있는 것을 확인할 수 있습니다.
글 등록시 액세스 토큰이 유효하여 인증 필터를 통과하고 글 작성이 가능합니다.
액세스 토큰을 삭제 후 글 등록을 시도했습니다. 액세스 토큰은 유효하지 않지만 리프레시 토큰이 있어 /tokenAPI를 호출해 새 액세스 토큰을 발급받아 다시 요청해 인증 필터를 통과해 글이 등록되었습니다.
작성하지 않은 글의 수정과 실패는 되지 않습니다.
요약
1. 쿠키란 사용자가 어떠한 웹사이트에 방문했을 때 그 웹사이트가 사용하는 서버를 통해 로컬에 저장되는 작은 데이터입니다. 키와 값으로 이루어져 있으며 만료기간, 도메인 등의 정보를 갖습니다.
2. OAuth는 제3의 서비스에게 계정을 맡기는 방식입니다. 정보를 취득하는 방법은 권한 부여 코드 승인 타입, 암시적 승인 타입, 리소스 소유자 암호 자격증명 승인 타입, 클라이언트 자격 증명 승인 타입으로 나뉩니다.
3. OAuth 방식 중 권한 부여 코드 승인 타입은 클라이언트가 리소스에 접근하는데 사용되며 권한에 접근할 수 있는 코드를 제공받으려면 리소스 오너에 대한 액세스 토큰을 제공받게 됩니다.