위는 정수원님의 OAuth2 강의를 참조하여
기존 OAuth2 로그인 코드에 배운 내용을 적용한 것입니다.
보호자원서버에서 사용자의 자원(email,프로필 등) 을 가지고 오려면 accessToken 이 필요하다 . 여기서 accessToken 은 인가 서버 즉 네이버가 걍 발급해주는 것이므로 우리가 만들거나 이런 것은 아니다.
대략 이런 식의 흐름을 가진다.
우리가 사용할 OAuth2 Client type은 client_secret 의 기밀성을 유지할 수 있는 기밀 클라이언트이다.(반대는 공개 클라이언트)
기밀 클라이언트의 대표적인 방식인 authorization code 의 방식은 아래 그림과 같다.
먼저 인가 서버에 code 를 요청하고
client 는 code 를 받고
다시 그 코드로 Auth-Server 에 전달하면
accessToken을 받을 수 있다.(다시 말하지만 이건 그냥 네이버가 발급해주는 것이다)
인가 서버에 code 를 요청할 때
이 화면을 생각하면 편하다.
위에 Url 을 보자.
처음에 code 를 요청할 떄는 client-secret 값이 필요하지 않다.
대부분 이런 형식일 것이다.
redirect_uri=http://localhost:8080/login/oauth2/code/naver
redirect_uri=http://localhost:8080/login/oauth2/code/google
이런 식으로 다들 써주었기 때문이다.
그런데 중요한 것은 이따가 application. yml 혹은 application.properties 에서 redirection_url 을 써줄 것인데(네이버의 경우) 이 값과 정확히 똑같이 맞춰야 한다.!!!
토시하나 안틀리고 /login/oauth2/code/google or naver 를 고정하자.
스프링 내부의 filter로 인해서 이 url 로 매칭시켜 내부적으로 code를 받아온 redirection url을 바탕으로 다시 인가서버로 accessToken 을 요청하기 때문이다!!!
이 필터이다.
물론 기존 filter를 상속받아 새로운 필터를 만들어서 해결할 수도 있지만 그건 쫌 귀찮은 작업일 것 같다.
물론 배포할 때는 IP주소:8080/login/oauth2/code/google 이런 식으로 승인된 리디렉션 url를 추가하면 된다.
그냥 끝만 고정해주자.
Authorization Code 방식의 흐름을 다시한번 보면
이런 식이다.
Authorization code 방식 이외에도 Implicit Grant 방식, Resouce Owner Password Credentials Grant 방식, Client Credentials Grant 방식, 등등이 많지만 우리가 소셜 로그인을 구현하려면 Authorization code 방식을 사용할 것이고, 다른 방식은 필요하면 공부하도록 하자!
에 좋은 그림이 있다.
const NAVER_AUTH_URL =
API_BASE_URL +
"/oauth2/authorization/naver";
API_BASE_URL 은 서버 주소:8080 이런 식으로 설정하면 된다.
이 주소를 백에게 보내면
위 필터가 /oauth2/authorization/provider 를 감지하여
이 화면을 띄우도록 한다.
그런데 이 화면에는 client_id 등등이 들어가 있는데 이거는 어떻게 띄우는 것일까??
실은 이미 우리 백앤드 서버가 돌고 있을 떄 application.properties 또는 yml 을 바탕으로 다 만들어 둔다.
일단 build.gradle 부터 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
//oauth gradle 추가
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
//JWT 추가
implementation 'com.auth0:java-jwt:4.2.1'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
security:
oauth2:
client:
registration:
google:
client-id: 발급받은 client id
client-secret: 발급 받은 cleint-secret
scope:
- profile
- email
naver:
client-id: 발급 받은 client-id
client-secret: 발급 받은 client-secret
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "http://localhost:8080/login/oauth2/code/naver"
scope:
- name
- email
- profile_image
client-name: Naver
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-info-authentication-method: header
user-name-attribute: response # Naver 응답 값 resultCode, message, response 중 response 지정
역시 가장 중요한 건 redirect-url 이다. 이것은 배포하면 배포한 http:// IP 주소:8080/login/oauth2/code/naver 로 꼭 바꿔주자!
전체적인 과정은 위 그림과 같다. 요약하면 application.yml 의 정보를 바탕으로 ClientRegistration 을 채워두고 OAuth2Client 는 이를 참조하여 자원서버 인증서버 등과 통신한다.
ClientRegistration: 클라이언트의 실질적인 정보가 들어있는 클래스, 인가 서버에 등록되어 있는 클라이언트의 정보. 실제로 클라이언트와 인가서버와 통신 위해 필요한 엔드포인트등의 정보를 담은 클래스.
이런 식으로 정보가 들어가 있다.
글로벌 기업들은 이미 세팅되어 있다.
CommonOAuth2Provider 에 찾아서 들어가면 깃허브 등등이 있다.
private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
또한 이렇게 기본적인 baseRedirect url 이 있다. 우리는 이미 동일하게 설정하였다.
안된다!
open id를 써주면 상속해서 사용하는 OAuth2UserService 가 아니라 OidcUserService를 세팅하게 되서 안된다고 한다.
이제 받아온 코드를 인가서버에게 가서 AccessToken 으로 교환한다.
이런 과정을 거친다고 한다. 일단 인가 서버로부터 accessToken 을 가져오는 부분은 우리가 할게 없다. 걍 내비두면 된다.
이제 accessToken 을 가져왔으니 이걸 바탕으로 사용자의 자원(이메일, 프로필 사진) 을 갖고 있는 서버로 accessToken 을 가지고 요청하고 사용자의 자원을 가져옵시다!
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
private static final String NAVER="naver";
private static final String GOOGLE="google";
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//이제 resource 서버로 부터 accessToken 을 가져온 상태입니다. 그 accessToken 을 바탕으로
//아래 코드를 통해 세팅된 userInfo URL 을 바탕으로 USERINFO 정보를 가지고 올 수 있습니다.
OAuth2UserService<OAuth2UserRequest,OAuth2User> delegate=new DefaultOAuth2UserService();
OAuth2User oAuth2User=delegate.loadUser(userRequest);
String registrationId=userRequest.getClientRegistration().getRegistrationId();//현재 코드 상 naver 또는 google 이 나올 것입니다.
SocialType socialType=getSocialType(registrationId);//naver 또는 google 을 바탕으로 SocialType 을 Enum 형식으로 가지고 옵니다.
String userNameAttributeName=userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();//Naver 의 경우는 response 가 나올것이고 , google 의 경우는 sub 이 나올 것입니다.
//이는 밑에 DefaultOAuth2User 를 상속한 CustomOAuth2User에 활용됩니다. 기본적으로 DefalutOAuth2User 에 필요한 정보 입니다 .getName 을 위한 키로 활용되고 있습니다
//실제로 User 에 대한 정보가 담겨있습니다. 이메일 프로필 사진 등의 정보를 Map에 담아둡니다.
Map<String,Object> attributes=oAuth2User.getAttributes();
//NAVER 와 Google 등 여러 소셜 로그인 들의 사용자의 정보(이메일,프로필 사진) 을 주는 key 값이 다른데
//그것을 일반화하여 사용하면 편리하기 때문에 사용합니다.
OAuthAttributes extractAttributes=OAuthAttributes.of(socialType,userNameAttributeName,attributes);
//밑에서 보는 것처럼 사용자의 정보에서 email,socialType을 추출하여 DB에 존재하는 경우 그것을 가져오고
//처음 로그인한 경우 새로운 member 를 생성합니다.
Member createdMember=getMember(extractAttributes,socialType);
//CustomOAuth2User는 DefaultOAuth2User를 상속받습니다.
//1)DefaultOAuth2User에 필요한 권한 정보입니다.
//2)DefaultOAuth2User에 필요한 사용자의 특성 정보입니다.
//3)DefaultOAuth2User에 필요한 key 정보입니다(네이버:response, google: sub)
//email은 제가 임의로 추가해보았습니다. 사실상 이메일 정보는 사용자의 특성 정보에 있지만
//그래도 한번 넣어보았습니다. 이는 나중에 OAUth2SuccessHandler 즉 OAuth2인증이 성공한 이후에
//로직상 필요할 때 넣어주시면 됩니다.
return new CustomOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(createdMember.getRole().name())),
attributes,
extractAttributes.getNameAttributeKey(),
createdMember.getEmail()
);
}
//"naver","google" String 타입을 EnumType으로 변화시켜서 사용합니다.
private SocialType getSocialType(String registrationId){
if(NAVER.equals(registrationId)){
return SocialType.NAVER;
}
else if(GOOGLE.equals(registrationId)){
return SocialType.GOOGLE;
}
return null;
}
//email과 socialtype으로 member를 가져오는 메서드
private Member getMember(OAuthAttributes attributes,SocialType socialType){
Member findMember=memberRepository.findBySocialTypeAndEmail(socialType,attributes.getOauth2UserInfo().getEmail()).orElseGet(()->saveMember(attributes,socialType));
return findMember;
}
//처음 로그인한 경우 member 를 저장하는데 password는 임의로 저장하였습니다.
// 그 이유는 소셜로그인의 경우는 비밀번호가 넘어오지 않습니다(당연히)
// 하지만 만약 소셜 로그인 뿐만 아니라 우리의 일반 로그인도 사용한다면 비밀번호에 대한 password 필드가 존재합니다.(BCrypt 화 시켜서 저장시켜야 합니다)
//하지만 소셜로그인은 비밀번호를 넣을게 없으므로 UUID로 random 하게 생성하였습니다.
private Member saveMember(OAuthAttributes attributes,SocialType socialType){
Member createdMember=attributes.toEntity(socialType,attributes.getOauth2UserInfo(),UUID.randomUUID()+"password");
return memberRepository.save(createdMember);
}
}
위에서 loadUser는 OAuth2UserRequest 를 받아 OAuth2User 를 가져온 후 CustomOAuth2User 를 리턴하는 역할을 합니다.
이 과정이며
OAuth2UserRequest 는
으로 구성되어 있습니다. 즉 사용자의 자원을 가져오기 위한 accessToken 과 어디로 사용자의 자원을 요청해야 하는지 (ClientRegistration 내 정보) 등등 필요한 정보가 있습니다.
이후
CustomOAuth2User를 반환하면 그 후 과정은 OAuth2AuthenticationToken 을 거쳐서 SecurityContext 에 담겨 Authentication 으로 조회할 수 있습니다.
OAuth2User oAuth2User=delegate.loadUser(userRequest); 를 통해 사용자의 자원을 가지고 오며 이를 통해 CustomUser를 반환합니다.
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private String email;
public CustomOAuth2User(
Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey,String email) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
}
}
email 은 나중에 attributes 에서 꺼낼수 있어서 안써줘도 되나 그냥 한번 써봤습니다.!
이후에 return 된 CustomUser 를 SecurityContext 에 담는 작업을 스프링이 알아서 해줍니다 .
이후 SecurityContext 에 담긴 것을 메서드 파라미터에 Authentication 을 써줌으로써 가져올 수 있습니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final ObjectMapper objectMapper;
private final RedisTemplate<String,String> redisTemplate;
//소셜 로그인이 성공하고 마무리 단계입니다.
//Authentication.getPrincipal 안에는 우리가 만들었던 CustomOAuth2User가 들어있습니다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 로그인 성공!");
//accessToken 과 refreshToken 을 각각 만들어서 반환합니다.
try{
CustomOAuth2User oAuth2User=((CustomOAuth2User) authentication.getPrincipal());
//getName 은 아까 nameAttribute key 를 바탕으로 그 key 에 대한 값을 가지고 옵니다.
//아까 naver 의 경우 저희는 키가 response 임을 확인하였습니다. 이 경우 반환값은 데이터 전체일 것입니다 ex:{id=xATIv6lgOcf-zKpGuCMjpPMGcG1YSWJZg2dMBMq8B-M, nickname=이진우, profile_image=https://phinf.pstatic.net/contact/20230615_207/1686815329264fVtbk_PNG/%BD%BA%C5%A9%B8%B0%BC%A6_2023-06-15_164754.png, email=dionisos198@naver.com, name=이진우}
//google 의 경우 sub 이였으므로 sub에 해당하는 값이 넘어오겠습니다.
//솔직히 왜 필요한지는 모르겠습니다. ㅎ
System.out.println(oAuth2User.getName());
TokenDto tokenDto=tokenProvider.createTokenByOAuth(oAuth2User);//OAuth2로 새로운 access,refreshToken 생성
// TokenResponseDto 객체 생성:
TokenResponseDto tokenResponseDto = new TokenResponseDto(tokenDto.getType(),tokenDto.getAccessToken(),tokenDto.getRefreshToken(),tokenDto.getAccessTokenValidationTime());
//redis 에 email 을 키 값: refreshToken 을 값으로 저장합니다.
redisTemplate.opsForValue().set(oAuth2User.getEmail(),tokenDto.getRefreshToken(),tokenDto.getRefreshTokenValidationTime(), TimeUnit.MILLISECONDS);
// Dto 객체를 JSON으로 변환하여 응답으로 전송
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), tokenResponseDto);
}catch (Exception e){
throw e;
}
}
}
이제 이 정보를 바탕으로 우리가 직접 토큰을 생성해서 토큰을 만들고
반환하면 됩니다.
반환할 때는 쿠키에 값을 넣거나, url 파라미터를 통해 값을 넣거나 이런 식으로 선택할 수 있습니다. 헤더에 accessToken ,refreshToken 을 넣고 response.sendRedirect 를 하면 로컬에서 돌릴 떄는 되는데 프론트랑 연동하면 헤더가 아예 사라질 수 있으니 주의합니다.
이 부분에 대해 공부가 필요해보입니다.(response.sendRedirect )
저는 편리하게 실험하려고 Body에 넣고 걍 전송해버렸습니다.
테스트 하는 상황은 다음과 같습니다.
GUEST 는 USER 가 접근 가능한 자원에 접근 할 수 없다.
처음 로그인시 GUEST로 역할을 고정한다.
GUEST 는 특정행위를 수행하면 Role,Authority 를 USER로 바꿀수 있다.
그러면 USER 의 권한으로 USER가 접근 가능한 자원에 접근 한다.
403에러
역할 변경으로 인한 토큰 재발급
필요한 사람이 있으면 업로드할게요 ㅠ 누가 봐줘 제발 ㅎ
글 너무 잘봤습니다! 저도 현재 진행하는 프로젝트에서 프론트 연동 시 sendredirect에서 쿠키가 사라지는 문제가 발생하네요ㅠㅠ 혹시 실례가 안된다면 깃허브 주소를 알 수 있을까요??ㅠㅠ