OAuth
: 제 3의 서비스에 계정 관리를 맡기는 방식
리소스오너 : resource owner
주체
송재근
리소스 서버 : resource server
스프링 부트
인증 서버 : authorization server
카카오 인증 서버
클라이언트 애플리케이션 : client application
지금 만들고 있는 서비스가 이에 해당
권한 부여 코드 승인 타입(중요) : authorization code grant type
정확하게는 스프링부트가 송재근의 정보에 접근하는데 사용
권한에 접근할 수 있는 코드
와 리소스 오너에 대한 엑세스 토큰
을 발급받는 형식사용자의 데이터가 외부로 나가지 않아 안전
클라이언트 자격증명 승인 타입(중요) : client credentials grant
송재근이 액세스 토큰을 얻어 사용하는 방식
암시적 승인 타입 : implicit grant type
리소스 소유자 암호 자격증명 승인 타입 : resource owner password credentials
권한 부여 코드 승인 타입
1. 권한 요청
: 스프링 부트 서버가 특정 사용자 데이터에 접근하기 위해 권한서버(= 카카오, 구글 권한 서버)에 요청을 보내는 것
client_id
: 인증 서버가 클라이언트에 할당한 고유 식별자
redirect_uri
: 로그인 성공 시, 바로 이동하는 URI
response_type
: 클라이언트가 제공받길 원하는 응답 타입
나 이거줘!
scpoe
: 제공받고자 하는 리소스 오너의 정보 목록
2. 데이터 접근용 권한 부여
인증 서버에 요청이 처음인 경우(최초 1회만 진행)
최초 1회 진행 후
, 인증 서버에서는 동의 내용을 가지고 있기 때문에 로그인만 진행
로그인 성공?
-> 권한 부여 서버는 데이터에 접근할 수 있게 인증 및 권한 부여 수신
3. 인증 코드 제공
사용자 로그인 성공
: 권한 요청 시, redirect_url로 리다이렉션
4. 액세스 토큰 응답
인증 코드 받으면 액세스 토큰으로 교환해야함
액세스 토큰
: 로그인 세션에 대한 보안 자격을 증명하는 식별코드
/token POST 요청
을 보냄client_sercret
: OAuth 서비스에 등록할 때 제공받는 비밀키grant_type
: 권한유형 확인할는데 사용authorization code로 설정
하기권한 서버는 요청 값을 기반으로 유효한 값인지 확인 후, 유효한 정보면 액세스 토큰으로 응답
5. 액세스 토큰으로 API 응답 & 반환
// 권한 요청을 위한 파라미터, Client(스프링서버) -> 인증서버(구글, 카카오)
GET spring-authorization-server.example/authorize?
client_id=66asd128f2jf2&
redirect_uri=http://localhost:8080/myapp&
response_type=code&code값
scope=profile
// 인증 코드, 권한 요청 시, redirect_url로 리다이렉션
GET http://localhost:8080/myapp?code=asd1234452dasd1234452d
// 액세스 토큰 요청
POST spring-authorization-server.example.com/token
{
"client_id": "66asd128f2jf2&",
"client_secret": "aabb112233",
"redirect_uri": "http://localhost:8080/myapp",
"grant_type": "authorization_code",
"code": "asd1234452d"
}
.
.
.
// 액세스 토큰 응답
{
"access_token" : "aasdffg",
"token_type": "Bearer ",
"expires_in": 3600,
"scope": "openod profile",
.
.
.
}
// 리소스 오너의 정보를 가져오기 위한 요청
GET spring-authorization-resource-server.example.com/userinfo
Header: Authorization: Bearer aasdffg
쿠키
: 사용자가 어떠한 웹사이트 방문 시, 해당 웹사이트의 서버에서 여러분의 로컬환경에 저장하는 작은 데이터
방문의 유무 체크
로그인 상태 유지
Key, Value
만료 기간, 도메인 등의 정보
HTTP 요청을 통해 쿠키의 특정 키에 값을 추가 가능
순서
책보면서 따라하기
마지막에 나오는 client-id + client-secret
gitgnore에 yml에 넣기 -> 외부노출 절대 X
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
datasource:
url: jdbc:h2:mem:testdb
username: song
h2:
console:
enabled: true
security:
oauth2:
client:
registration:
google:
client-id: 여기에 넣기~!
client-secret: 여기에 넣기~!
scope:
- email
- profile
jwt:
issuer: jaegeunsong97@gmail.com # 이슈 발급자
secret_key: study-springboot # 비밀키
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
addCookie()
: 요청값(이름, 값, 만료기간)을 바탕으로 HTTP 응답에 쿠키를 추가deleteCookie()
: 쿠키 이름을 입력받아 쿠키 삭제실제 삭제 불가
생성하자마자 만료
serialize()
: 객체
를 직렬화해 쿠키의 값
으로 들어갈 값으로 변환deserialize()
: 쿠키
를 역직렬화해 객체로
package me.songjaegeun.springbootdeveloper.util;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.SerializationUtils;
import java.util.Base64;
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())));
}
}
package me.songjaegeun.springbootdeveloper.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@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")
private String password;
@Column(name = "nickname", unique = true)
private String nickname; // oauth2
@Builder
public User(String email, String password, String nickname) {
this.email = email;
this.password = password;
this.nickname = nickname; // oauth2
}
/**
* OAuth2
*/
public User update(String nickname) {
this.nickname = nickname;
return this;
}
/**
* Security
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("user"));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
loadUser()
: DefaultOAuth2UserService에서 제공OAuth2로 접속한 객체의 식별자, 이름, 이메일, 프로필 사진 링크 담고 있음
package me.songjaegeun.springbootdeveloper.config.oauth;
import lombok.RequiredArgsConstructor;
import me.songjaegeun.springbootdeveloper.domain.User;
import me.songjaegeun.springbootdeveloper.repository.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Map;
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 요청을 바탕으로 유저 정보를 담은 객체 반환
OAuth2User user = super.loadUser(userRequest); // OAuth2UserRequest에서 사용자 조회
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);
}
}
OAuth2 설정 파일 작성
OAuth2와 JWT 사용
: 기존 Spring Security 구현하면 다르게 바꾸기
폼 로그인 방식인 WebSecurityConfig 전부 주석
/config/WebOAuthSecurityConfig
filterChain()
: 토큰 방식으로 인증, 기존 폼 로그인, 세션 기능 비활성화
addFilterBefore()
: 헤더값을 확인
할 커스텀 필터 추가authorieRequests()
: 토큰 재발급 URL은 인증 없이, 나머지 API 인증 필요
oauth2Login()
: OAuth2에 필요한 정보
를 세션이 아닌 쿠키에 저장
해서 사용하도록 인증 요청과 관련된 상태를 저장할 저장소를 설정
/config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository
OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 사용하도록 인증 요청과 관련된 상태를 저장할 저장소
AuthorizationRequestRepositry
: 권한 인증 흐름
에서 클라이언트의 요청을 유지
하는 데 사용쿠키를 사용해 OAuth 정보를 가져오고 저장
package me.songjaegeun.springbootdeveloper.config.oauth;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import me.songjaegeun.springbootdeveloper.util.CookieUtil;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.web.util.WebUtils;
// OAuth2에 필요한 정보를 쿠키에 담아서 저장하는 저장소
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements
AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
// AuthorizationRequestRepository : 권한 인증 흐름에서 클라이언트 요청 유지하게 해줌
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);
}
}
인증 성공시 핸들러
UserService
package me.songjaegeun.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.songjaegeun.springbootdeveloper.domain.User;
import me.songjaegeun.springbootdeveloper.dto.AddUserRequest;
import me.songjaegeun.springbootdeveloper.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@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")
);
}
}
스프링 시큐리티 기본 로직
: authenticationSuccessHandler 지정 않하면 로그인 성공 이후 -> SimpleUrlAuthenticationSuccessHandler 사용일반적인 로직은 동일
하게토큰 관련된 작업만 추가로 처리
하기 위해 SimpleUrlAuthenticationSuccessHandler 상속받고 onAuthenticationSuccess() 메소드를 오버라이드
package me.songjaegeun.springbootdeveloper.config.oauth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.songjaegeun.springbootdeveloper.config.jwt.TokenProvider;
import me.songjaegeun.springbootdeveloper.domain.RefreshToken;
import me.songjaegeun.springbootdeveloper.domain.User;
import me.songjaegeun.springbootdeveloper.repository.RefreshTokenRepository;
import me.songjaegeun.springbootdeveloper.service.UserService;
import me.songjaegeun.springbootdeveloper.util.CookieUtil;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.time.Duration;
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// SimpleUrlAuthenticationSuccessHandler : 일반적인 로직 동일하게, 추가를 위해 onAuthenticationSuccess Override
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. 리프레시 토큰 생성 -> DB저장 -> 쿠키에 저장
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken); // 토큰 만료 시, 재발급 위해 refreshToken 저장
// 2. 액세스 토큰 생성 -> 패스에 액세스 토큰 추가
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 3. 인증 관련 설정값, 쿠키 제거
clearAuthenticationAttributes(request, response);
// 4. 리다이렉트
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 생성된 리프레시 토큰을 전달받아 DB에 저장
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); // OAuth 인증을 위한 정보 삭제
}
// 액세스 토큰을 패스에 추가
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.toUriString();
}
}
package me.songjaegeun.springbootdeveloper.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@Column(name = "author", nullable = false)
private String author; // oauth
@CreatedDate
@Column(name = "created_at")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Builder
public Article(String author, String title, String content) {
this.author = author; // oauth
this.title = title;
this.content = content;
}
/**
* 의미있는 메소드
*/
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
package me.songjaegeun.springbootdeveloper.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.songjaegeun.springbootdeveloper.domain.Article;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
private String title;
private String content;
public Article toEntity(String author) { // oauth
return Article.builder()
.title(title)
.content(content)
.author(author) // oauth
.build();
}
}
public class BlogService {
.
.
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
.
.
public class BlogApiController {
.
.
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
// principal 현재 인증 정보를 가지고 있음
Article savedArticle = blogService.save(request, principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(savedArticle);
}
.
.
package me.songjaegeun.springbootdeveloper.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.songjaegeun.springbootdeveloper.domain.Article;
import java.time.LocalDateTime;
@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();
}
}
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목1', '내용1', 'user1', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목2', '내용2', 'user2', NOW(), NOW())
INSERT INTO article (title, content, author, created_at, updated_at) VALUES ('제목3', '내용3', 'user3', NOW(), NOW())
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>블로그 글</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
</head>
<body>
<div class="p-5 mb-5 text-center</> bg-light">
<h1 class="mb-3">My Blog</h1>
<h4 class="mb-3">블로그에 오신 것을 환영합니다.</h4>
</div>
<div class="container mt-5">
<div class="row">
<div class="col-lg-8">
<article>
<input type="hidden" id="article-id" th:value="${article.id}">
<header class="mb-4">
<h1 class="fw-bolder mb-1" th:text="${article.title}"></h1>
<div class="text-muted fst-italic mb-2" th:text="|Posted on ${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm')} By ${article.author}|"></div>
</header>
<section class="mb-5">
<p class="fs-5 mb-4" th:text="${article.content}"></p>
</section>
<button type="button" id="modify-btn"
th:onclick="|location.href='@{/new-article?id={articleId}(articleId=${article.id})}'|"
class="btn btn-primary btn-sm">수정</button>
<button type="button" id="delete-btn"
class="btn btn-secondary btn-sm">삭제</button>
</article>
</div>
</div>
</div>
<script src="/js/article.js"></script>
</body>