LostCatBox

SpringProject-Board-CH09

Word count: 1.7kReading time: 10 min
2022/12/24 Share

Spring 게시판 프로젝트 9편 (Oauth2 적용)

Created Time: August 10, 2022 2:33 PM
Last Edited Time: October 25, 2022 11:31 PM
References: https://velog.io/@swchoi0329/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84
https://deeplify.dev/back-end/spring/oauth2-social-login

Tags: Java, Spring, Computer

왜?

로그인시에 구글계정을 통해 내 서비스에서 로그인하는 것을 구현하고싶었다.

현 상황은?

리프레쉬 토큰을 활용하지 않고, access token으로만 인증 처리를 하고있다. 따라서 refresh token을 사용하지 않고, OAuth2.0을 적용하는 것이 관건이였다.

스프링 시큐리티+OAuth 2.0

스프링 시큐리티(Spring Security)는 막강한 인증과 인가(혹인 권한 부여) 기능을 가진 프레임워크 사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준

스프링 시큐리티와 스프링 시큐리티 Oauth2클라이언트

OAuth

오픈 아이디의 표준적인 방법을 고려하던 사람들은 OAuth 라는 규격을 만들고 2007년 10월 03일 OAuth 1.0 을 발표하게 되었습니다.

이휴 OAuth 2.0까지 발전하였다.

로직

Untitled

$1. 클라이언트→내 서버 OAuth2지정로 지정된 요청

2. 내 서버→클라이언트 redirect_uri주소를 담은 응답으로 redirect 302를 주며 Authorization Server로 redirect 시킴

쿼리로redirect_uri=http://localhost:8080/login/oauth2/code/google 담고있음

Untitled

3.클라이언트→Authorization Server 리다이렉트로 로그인성공할 경우, Authorization Server→내 서버에 Authorization Code Response로 응답

4. 내 서버→ Authorization Server 요청시 Access Token Request With Authorization Code

5. Authorization Server→내 서버 Access Token을 응답함

6. 내서버→ Resource Server에 User Info with Access Token으로 요청함

7. ResourceServer→ 내서버 UserInfo응답함

8. 내 서버에서의 처리

  • user info를 DB저장
  • jwt accesstoken 발급

9. 내 서버→클라이언트에게 서버가 원하는 주소로 redirect로 시킴( 내서버의 access-token 값 전달!)

  • redirect query에 accesstoken포함

  • 그림엔 나오지 않았지만, 3번에 클라이언트→Authorization Server로 요청에 대해 다시한번 리다이렉트가 오며, 이때 아래 그림에 해당하는 주소가 내 서버이므로 요청 보낸 후 내서버의 응답을 기다리게된다. 그리고 9번에서 설명하는 spring에서 원하는 값을 redirect형식으로 응답하게 된다면, 결국 클라이언트→해당주소로 요청하게된다(10번 참조)(혹시나, spring에서 응답을 하지않을경우 이 요청이 만료된다)

    ss

10. 9번 클라이언트는 해당주소로 요청,이후 localStorage에 accesstoken을 갖게됨.

  • 해당 리다이렉트 주소로 요청후 200을 받게된다
  • redirect된 링크에 요청을 보낼때 uri에 query에는 access-token값이 있으므로 vue에서 링크에 해당하는 페이지가 created()될때 vue에서 localStorage에 저장하게만듬
  • 이후 프론트 백엔드 간의 요청은 accesstoken 인증가능함

구현

구글 서비스 등록

구글 서비스에 신규 서비스를 생성. 여기서 발급된 인증 정보(clientId와 clientSecret)를 통해 로그인 기능과 소셜 서비스 기능을 사용할 수있으니 무조건 발급 받고 시작한다.

https://deeplify.dev/back-end/spring/oauth2-social-login

위에 링크를 참조하자

마지막 OAuth 클라이언트를 생성하고 나면 클라이언트 ID와 클라이언트 보안 비밀번호를 얻을수있으며, 이 id와 secret은 스프링 OAuth설정시 사용되므로 잘 복사해줘야한다.

Spring 구현

apllication-oauth.properties

1
2
3
spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀번호
spring.security.oauth2.client.registration.google.scope=profile,email
  • scope=profile,email
    • 많은 예제에서는 이 scope를 별도로 등록하지 않고 있습니다.
    • 기본값이 openid,profile,email이기 때문입니다.
    • 강제로 profile,email를 등록한 이유는 openid라는 scope가 있으면 Open id Provider로 인식하기 때문입니다.
    • 이렇게 되면 Open id Provider인 서비스(구글)와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 합니다.
    • 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록합니다.

application.properties추가

1
spring.profiles.include=oauth

.gitignore 에 추가

1
application-oauth.properties

스프링 스큐리티 설정

먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나 추가한다.

1
2
//spring-boot-starter-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리함
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
  1. spring-boot-starter-oauth2-client
  • 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
  • spring-boot-starter-oauth2-client와 spring-security-oauth2-josn를 기본으로 관리해줍니다.

build.gradle 설정이 끝났으면 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성합니다.

  • config 패키지 생성
    • 시큐리티 관련 클래스는 모두 이곳에 생성한다.

SecurityConfig 클래스 생성

OAuth2로그인 기능, endpoint로 소셜 로그인 성공시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다.

리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할수있다.

또한, 모든 처리후 successHandler를 지정해줌으로써, 핸들러에서 targeturl지정 access-token발급 및 redirect 유도

1
2
3
4
5
6
7
8
9
protected void configure(HttpSecurity http) throws Exception {
http
// oauth관련 설정
// oauth관련 설정 추가
.oauth2Login() // OAuth2로그인 기능에 대한 여러 설정의 진입점
.userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정을 담당
.userService(customOAuth2UserService) // 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다., 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할수있다.
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)

CustomOAuth2UserService 생성

  • 구글 로그인 이후 가져온 사용자의 정보(email.name 등) 들을 기반으로 가입 및 정보수정, 세션 저장등의 기능을 지원한다.
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
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);

// 현재 로그인 진행중인 서비스를 구분하는 코드.(현재 구글만하므로 필요없음 확장성)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드값을 이야기한다. PrimaryKey의미, 구글의 경우 기본적 코드 지원, 네이버 카카오는 지원안함. 구글 기본 코드는 "sub"이다
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

// OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

User user = saveOrUpdate(attributes);

return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes){
//구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName()))
.orElse(attributes.toEntity()); // 처음가입시 toEntity()로 생성됨.

return userRepository.save(user);

}
}

OAuthattributes

OAuth에서 가져온 attributes를 받는 DTO역할을 한다.

OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스

  • of()
    • OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 합니다.
  • toEntity()
    • User 엔티티를 생성합니다.
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때입니다.
    • 가입할 때의 기본 권할을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용합니다.
    • OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성합니다.
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
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes; //attributes로 모든 정보가 들어옴
private String nameAttributeKey;
private String name;
private String email;

@Builder
public OAuthAttributes(Map<String, Object> attributes,
String nameAttributeKey, String name,
String email) {
this.attributes = attributes;
this.nameAttributeKey= nameAttributeKey;
this.name = name;
this.email = email;
}

// OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야한다.
public static OAuthAttributes of(String registrationId,
String userNameAttributeName,
Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}

private static OAuthAttributes ofGoogle(String userNameAttributeName,
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}

// 가입할때 기본 권한 User를 주며, User엔티티를 생성, OAuthAttributes에서 엔티티를 생성하는 시점은 처음가입시이다.
public User toEntity() {
return User.builder()
.username(name)
.email(email)
.roles(Collections.singletonList("ROLE_USER"))
.build();
}

}

OAuth2AuthenticationSuccessHandler

access token 값과 refresh token 전달 역할

결국 oauth가 끝난후, session에… refresh token을 넣어줘야하는구나…

쿠키를 이용하지 않으면 구현이 불가능??

결국에 직접 redirect를 해당 요청자에게 전달해줘야하고, 이떄 successHandler와 failureHandler를 구현하여, 성공시 DB에는 refreshToken을 저장하고 UriComponentsBuilder 로 token값으로 queryparam를 통해 access token값을주고, cookie도 refresh token을 넣어줌

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
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
private final UserRepository userRepository;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);

if (response.isCommitted()){
logger.debug("Response has already been committed. Unable to redirect to" + targetUrl);
return;
}
clearAuthenticationAttributes(request);
getRedirectStrategy().sendRedirect(request,response,targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String targetUrl = "http://localhost:8081/loginWithOAuth";
DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) authentication.getPrincipal();//OidcUser??DefaultOidcUser??
User user = userRepository.findByEmail(defaultOAuth2User.getAttribute("email")).orElseThrow(EmailNotFailedCException::new);

String token = jwtProvider.createToken(String.valueOf(user.getId()),user.getRoles()); //role 안잡힐껄?
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token",token)
.build().toUriString();
}

}

Front-end vue

로직

  • 요청을 Google Login 클릭시 해당 서버로 요청 보내도록함
    <a href="http://localhost:8080/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>

router

1
2
3
4
5
6
7
8
import LoginWithOAuth from "@/views/auth/LoginWithOAuth";

//아래 등록
{
path: '/loginWithOAuth',
name: 'LoginWithOAuth',
component: LoginWithOAuth // 로그인 OAuth시에는 다른곳으로 받아 store로 토큰저장`
},

LoginWithOAuth.vue

  • OAuth로 로그인시 accesstoken값이 query로 들어옴에 따라 이것이 있다면, board/list 로 이동// 없다면 login페이지로 이동
  • accesstoken값은 localStorage에 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<h2>Loginning~~</h2>
</template>
<script>

export default {
created () {
// 컴포넌트 렌더링이 되었을 때,

// 쿼리스트링으로부터 토큰을 획득
const token = this.$route.query.token
console.log('token', token)

// 토큰이 존재하는 경우, localStorage에 저장후 board/list로 이동 //없다면 login페이지로 이동
if (token) {
localStorage.setItem('authorization', token);
this.$router.replace('/board/list');
} else {
this.$router.replace('/login')
}

},
}
</script>

오류 해결

Authorization_request_not_found

  • 나같은 경우, authentication에서 추출하여 해당 email을 가진 user가 없을시 userrepository.save()를 할때, User 모델에서 nullable=false 필드인 nickname 필드가 null이였으므로, 오류가 났고, 이는 다시 처음부터 일어나는 현상이 생김 반복하다가 결국에, 마지막에 뜨는 오류였다.

해결

해당 필드 null 가능하도록 제약 조건 삭제함

access_token과 refresh_token이 둘다 필요한 이유

https://stackoverflow.com/questions/3487991/why-does-oauth-v2-have-both-access-and-refresh-tokens

(???) 읽어보고 다음 이슈로 해결하기

cannot be cast to class

authentication.getPrincipal()

을 활용하여, 현재 인증한 계정에 대해서 email계정을 얻어오기위해 (User)로 형변환시도하다 위와같은 에러가 나왔다.

해결책

debug에서 authentication.principal은 {DefaultOAuth2User@14764}였고, 이는 DefaultOAuth2User 객체였다. 따라서 형변환시에는 종속관계에 User가 없엇으므로 cannot be cast to class가 떴던것이다.

이는 CustomOAuth2UserService에서 loadUser를 통해 OAuth2User의 상속인 DefaultOAuth2User 의 객체를 반환했기때문이다.

따라서 DefaultOAuth2User로 형변환 성공하였다.

CATALOG
  1. 1. Spring 게시판 프로젝트 9편 (Oauth2 적용)
  2. 2. 왜?
  3. 3. 현 상황은?
  4. 4. 스프링 시큐리티+OAuth 2.0
  5. 5. 스프링 시큐리티와 스프링 시큐리티 Oauth2클라이언트
    1. 5.1. 로직
      1. 5.1.1. $1. 클라이언트→내 서버 OAuth2지정로 지정된 요청
      2. 5.1.2. 2. 내 서버→클라이언트 redirect_uri주소를 담은 응답으로 redirect 302를 주며 Authorization Server로 redirect 시킴
      3. 5.1.3. 3.클라이언트→Authorization Server 리다이렉트로 로그인성공할 경우, Authorization Server→내 서버에 Authorization Code Response로 응답
      4. 5.1.4. 4. 내 서버→ Authorization Server 요청시 Access Token Request With Authorization Code
      5. 5.1.5. 5. Authorization Server→내 서버 Access Token을 응답함
      6. 5.1.6. 6. 내서버→ Resource Server에 User Info with Access Token으로 요청함
      7. 5.1.7. 7. ResourceServer→ 내서버 UserInfo응답함
      8. 5.1.8. 8. 내 서버에서의 처리
      9. 5.1.9. 9. 내 서버→클라이언트에게 서버가 원하는 주소로 redirect로 시킴( 내서버의 access-token 값 전달!)
      10. 5.1.10. 10. 9번 클라이언트는 해당주소로 요청,이후 localStorage에 accesstoken을 갖게됨.
  6. 6. 구현
    1. 6.1. 구글 서비스 등록
    2. 6.2. Spring 구현
      1. 6.2.1. apllication-oauth.properties
      2. 6.2.2. application.properties추가
      3. 6.2.3. .gitignore 에 추가
      4. 6.2.4. 스프링 스큐리티 설정
      5. 6.2.5. SecurityConfig 클래스 생성
      6. 6.2.6. CustomOAuth2UserService 생성
      7. 6.2.7. OAuthattributes
      8. 6.2.8. OAuth2AuthenticationSuccessHandler
  7. 7. Front-end vue
    1. 7.1. 로직
    2. 7.2. router
    3. 7.3. LoginWithOAuth.vue
  8. 8. 오류 해결
    1. 8.1. Authorization_request_not_found
      1. 8.1.1. 해결
    2. 8.2. access_token과 refresh_token이 둘다 필요한 이유
    3. 8.3. cannot be cast to class
      1. 8.3.1. 해결책