📗 OAuth ⭐
📌 OAuth란?
제3서비스에 계정 관리를 맡기는 방식 (네이버 로그인, 구글 로그인)
🧩 OAuth 용어 정리
리소스 오너 (Resource Owner)
- 자신의 정보를 사용하도록 인증 서버에 허가하는 주체
→ 서비스 이용하는 사용자
리소스 서버 (Resource Server)
- 리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체
→ 네이버, 구글, 페이스북
인증 서버 (Authorization server)
- 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션
클라이언트 어플리케이션 (Client Application)
- 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체
→ 지금 만들고 있는 서비스
🧩 리소스 오너 정보를 취득하는 4가지 방법
① 권한 부여 코드 승인 타입 (Authorization code grant type)
- 클라이언트가 리소스에 접근하는 데 사용
- 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식
② 암시적 승인 타입 (Implicit grant type)
- 서버가 없는 JS 웹 애플리케이션 클라이언트에서 주로 사용하는 방법
- 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식
③ 리소스 소유자 암호 자격증명 타입 (Resource Owner Password Credentials)
- 클라이언트의 패스워드를 이용해 액세스 토큰에 대한 사용자의 자격 증명을 교환하는 방식
④ 클라이언트 자격증명 승인 타입 (Client Credentials Grant)
- 클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청할 때 사용하는 방식
📌 권한 부여 코드 승인 타입이란?
① 권한 요청
클라이언트가 특정 사용자 데이터에 접근하기 위해 권한 서버에 요청을 보내는 것
(=스프링부트 서버) (카카오/구글 권한 서버)
- 요청 URI : 클라이언트 ID, 리다이렉트 URI, 응답 타입 등
➡ client_id : 인증 서버가 클라이언트에 할당한 고유 식별자
➡ redirect_uri : 로그인 성공 시 이동해야 하는 URI
➡ response_type : 클라이언트가 제공받길 원하는 응답 타입
➡ scope : 제공받고자 하는 리소스 오너의 정보 목록
② 데이터 접근용 권한 부여
- 인증 서버에 첫 요청 보내는 경우, 사용자에게 보이는 페이지를 로그인 페이지로 변경하고 사용자의 데이터에 접근 동의를 얻는다. (최초 1회만 진행) 이후에는 인증 서버에 동의 내용을 갖고 있으므로 로그인만 진행.
③ 인증 코드 제공
- 사용자가 로그인 성공 시, 권한 요청할 때 파라미터로 보낸 redirect_url로 리다이렉션
(파라미터에 인증 코드 함께 제공)
GET http://localhost:8080/myapp?code=a1s2f3mcj2
③ 액세스 토큰 응답
*액세스 토큰 : 로그인 세션에 대한 보안 자격을 증명하는 식별 코드 (/token POST 요청)
➡ client_secret : OAuth 서비스에 등록할 때 제공받는 비밀키
➡ grant_type : 권한 유형 확인 시 사용
액세스 토큰으로 API 응답 & 반환
- 정보가 필요할 때마다 API 호출 통해 정보 가져오고, 리소스 서버는 토큰이 유효한지 검사한 뒤 응답
📌 쿠키란?
사용자가 어떤 웹사이트 방문 시 해당 웹사이트의 서버에서 로컬 환경에 저장하는 작은 데이터
- 구성 요소 : 키, 값, 만료 기간, 도메인 정보
- HTTP 요청 통해 쿠키의 특정 키에 값 추가 가능 (브라우저에서 요청 > 서버에서 쿠키 설정 > 브라우저에 쿠키 저장)
📗 토큰 발급받기
1. 구글 클라우드 콘솔에 접속한 후 [콘솔] 버튼 클릭
2. [프로젝트 선택] - [새 프로젝트]
3. [API 및 서비스 → 사용자 인증 정보] - [동의 화면 구성]
4. OAuth 동의 화면 구성하기
5. 값 입력하기
6. 범위 설정하기 (openid와 이메일 주소)
7. 테스트 사용자는 pass하고 [저장 후 계속] - [대시 보드로 돌아가기]
8. 대시보드에서 [사용자 인증 정보 → 사용자 인증 정보 만들기 → OAuth 클라이언트 ID]
9. OAuth 클라이언트 ID 만들기
( 만들고 나서 클라이언트 ID와 클라이언트 보안 비밀번호는 애플리케이션에서 사용하는 값이므로 따로 저장해두기 )
10. OAuth 설정값을 스프링 부트 애플리케이션 설정 파일에서 사용하고자 application.yml 파일 수정
📗 스프링 시큐리티로 OAuth2 구현하고 적용하기
1. 의존성 추가하기
- OAuth2 사용하고자 build.gradle 파일 수정
dependencies {
// ...
//
// OAuth2 사용하기 위한 스타터 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
2. 쿠키 관리 클래스 구현하기
1) util 패키지 - CookieUtil.java
public class CookieUtil {
// 요청값(이름, 값, 만료 기간) 바탕으로 HTTP 응답에 쿠키 추가
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 ck : cookies) {
if(name.equals(ck.getName())) {
ck.setValue("");
ck.setPath("/");
ck.setMaxAge(0);
response.addCookie(ck);
}
}
}
// 객체를 직렬화해 쿠키의 값으로 변환
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
// 쿠키를 역직렬화해 객체로 변환
public static <T> T deserialize(Cookie ck, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(ck.getValue())
)
);
}
}
3. OAuth2 서비스 구현하기
1) domain - User.java 파일에 사용자 이름과 OAuth 관련 키 저장하는 코드 추가하기
public class User implements UserDetails {
// ...
@Column(name = "nickname", unique = true) // OAuth 관련 키 저장
private String 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;
}
}
2) 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);
}
}
➜ loadUser() : 리소스 서버에서 보내주는 사용자 객체 정보 불러와 사용자 조회하는 메서드
➜ saveOrUpdate() : users 테이블에 사용자 정보가 있다면 이름을 업데이트하고, 없다면 users 테이블에 회원 데이터 추가
4. OAuth2 설정 파일 작성하기
1) WebSecurityConfig.java 파일 모두 주석처리하기
(OAuth2와 JWT 함께 사용하려면 스프링 시큐리티용 설정이 아닌 다른 설정 사용해야 하므로)
2) /config/WebOAuthSecurityConfig.java 파일 생성하고 코드 작성
코드 확인하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
|
@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();
}
}
|
cs |
3) config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository.java
- OAuth2에 필요한 정보를 쿠키에 저장해 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소
- 권한 인증 흐름에서 클라이언트 요청 유지할 때 사용하는 Authorization 클래스 구현 ➔ 쿠키 사용해 OAuth 정보 가져오고 저장
4) /service/UserService.java 파일 수정
- 인증 성공 시 실행할 핸들러
코드 확인하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
@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())
// 1) 패스워드 암호화
.password(encoder.encode(dto.getPassword()))
.build()).getId();
}
// 유저 ID로 유저 검색해 전달하는 메서드
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
// 이메일 입력받아 users 테이블에서 유저를 찾고, 없으면 예외 발생
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
}
|
cs |
5) config/oauth/OAuth2SuccessHandler.java
5. 글에 글쓴이 추가하기
1) domain/Article.java 파일 열어 author 변수 추가
@Column(name = "author", nullable = false)
private String author;
@Builder // 빌더 패턴으로 객체 생성
public Article(String author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
2) dto/AddArticleRequest.java 파일 수정해 작성자 추가 저장
// 생성자 사용해 객체 생성
public Article toEntity(String author) {
return Article.builder()
.title(title)
.content(content)
.author(author)
.build();
}
3) service/BlogService.java 파일의 save 메서드 수정
// [블로그 글 추가 메소드]
public Article save(AddArticleRequest request, String userName) {
return blogRepository.save(request.toEntity(userName));
}
4) controller/BlogApiController.java 현재 인증 정보를 가져오는 principal 객체를 파라미터로 추가
- 인증 객체에서 유저 이름 가져온 후 save() 메서드로 넘김
// HTTP 메소드가 POST일 때 전달받은 URL과 같으면 메소드로 매핑
@PostMapping("/api/articles")
// @RequestBody로 요청 본문 값 매핑
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request, Principal principal) {
Article savedArticle = blogService.save(request, principal.getName());
// 요청한 자원이 성공적으로 생성되었으며, 저장된 블로그 글 정보를 응답 객체에 담아 전송
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
5) 글 상세 페이지에서 글쓴이 정보가 보이도록 dto/ArticleViewResponse.java 파일 수정해 author 필드 추가
public class ArticleViewResponse {
//.. 생략..
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(); // 추가
}
}
6) 스프링 부트 애플리케이션이 실행될 때마다 데이터를 추가하고자 data.sql 파일에도 author 칼럼 추가
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())
7) 뷰에서 글쓴이 정보가 보이도록 뷰 수정 (article.html)
<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>
6. OAuth 뷰 구성하기
1) controller/UserViewController.java 파일의 login() 메서드의 뷰를 oauthLogin으로 변경
2) 로그인 화면에서 사용할 이미지를 구글 로그인 브랜드 페이지에서 다운로드
3) 압축파일 해제하고, signin-assets\signin-assets\Web (mobile + desktop)\png@1x\dark\web_dark_rd_SI@1x.png 파일 복사
4) /resources/static/img 디렉토리 만들어 복사한 파일 붙여넣고, 파일명을 google.png로 변경
5) /templates/oauthLogin.html 파일 생성해 로그인 화면에 위의 이미지로 OAuth 연결 버튼 생성
코드 확인하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
<!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>
|
cs |
6) HTML 파일과 연결할 js 파일 생성 (resources/js/token.js)
- 파라미터로 받은 토큰이 있다면, 로컬 스토리지에 토큰 저장
const token = searchParam('token')
if(token) {
localStorage.setItem("access_token", token);
}
function searchParam(key) {
return new URLSearchParams(location.search).get(key);
}
7) articleList.html에서 token.js 가져오도록 파일 수정
<script src="/js/token.js"></script>
8) resources/js/article.js 열어 createButton 관련 코드 수정
crateButton
- 토큰 기반 요청 사용
- POST 요청 보낼 때 액세스 토큰 함께 보냄
- 응답에 권한이 없다는 에러코드 발생 시, 리프레시 토큰과 함께 새로운 액세스 토큰 요청하고, 전달받은 액세스 토큰으로 다시 API 요청
deleteButton, updateButton
- httpRequest() 함수 사용해 삭제, 수정 기능
- 요청 헤더에 토큰 전달
7. 글 수정, 삭제, 글쓴이 확인 로직 추가하기
- BlogService.java
본인 글이 아닌데 수정,삭제 시도하는 경우 예외 발생시키기
📗 OAuth2 실행 테스트하기
1) OAuth 구글 로그인하기
: http://localhost:8080/login에 접속 - [구글로 로그인하기] 버튼 클릭하고 로그인하면, /article 로 리다이렉트 된다.
쿼리 파라미터에 token , 액세스 토큰을 요청 헤더로 전달한다.
2) 스프링 부트 애플리케이션으로 부터 전달받은 액세스 토큰 확인하기
: [도구 더보기] - [개발자 도구] 열어 [Application] - [Local Storage] 클릭해 저장하는 것 확인 가능
3) 리프레시 토큰 확인하기
: [Cookies]
4) 글 등록(/new-article)로 이동해 글 등록하기
📗 테스트 코드 실패 해결하고 코드 수정하기
BlogApiControllerTest.java 파일 수정하기
(해당 글 내용은 📗 스프링 부트 3 백엔드 개발자 되기 - 자바 편을 읽고 정리한 내용입니다.)
+ 네이버, 카카오 앱 로그인 방법
OAuth 2.0 + JWT + Spring Security로 회원 기능 개발하기 - 앱등록과 OAuth 2.0 기능구현
OAuth 2.0 로그인 기능을 구현하기 위해서는 꽤나 많은 작업이 필요합니다.이번 파트에서는 네이버, 카카오, 구글에 앱을 등록하고 OAuth 2.0을 본격적으로 사용할 수 있는 준비를 해보겠습니다.구글
velog.io
+ 스프링 시큐리티 관련 과정 참고 글/사진
Spring Security 와 OAuth 2.0 와 JWT 의 콜라보
Spring Boot, Spring Security, OAuth 2.0, JWT 와의 치열한 싸움 기록
velog.io
'📚 관련 독서 > 스프링 부트 3 백엔드 개발자 되기 - 자바 편' 카테고리의 다른 글
[SpringBoot] 12장 CI/CD 도입하기 (0) | 2024.02.27 |
---|---|
[SpringBoot] 11장 AWS에 프로젝트 배포하기 (0) | 2024.02.22 |
[SpringBoot] 09장 JWT ver. 로그인/로그아웃 구현 (0) | 2024.02.13 |
[SpringBoot] 08장 스프링 시큐리티 ver. 로그인/로그아웃, 회원 가입 구현 (0) | 2024.02.13 |
[SpringBoot] 07장 블로그 화면 구성하기 (타임리프) (0) | 2024.02.08 |