YS's develop story
Spring, OAuth2 + JWT 를 활용하여 소셜 로그인 구현하기 1편 (구글 및 네이버) [Spring 3.1.5, java 17] 본문
Spring, OAuth2 + JWT 를 활용하여 소셜 로그인 구현하기 1편 (구글 및 네이버) [Spring 3.1.5, java 17]
Yusang 2023. 12. 8. 07:26
글 작성하기에 앞서 ppt로 정리한 전체적인 동작 흐름입니다.
현재 제 프로젝트에서는 아래와 같이 OAuth를 통한 구글, 네이버 로그인을 구현했고
그 과정을 정리하려고 글을 작성하게 되었습니다.
http://console.cloud.google.com/project
프로젝트 만들기 -> API 및 서비스 -> 사용자 인증정보 -> 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID
승인할 리디렉션 URI를 설정 후
생성 후 사용자 인증 정보에서 클라이언트 ID 및 클라이언트 보안 비밀번호 확인 가능
이것을 복사해 놓습니다.
gradle에 추가
dependencies {
//oauth
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
}
들여 쓰기 유이하여 yml파일에 아래와 같이 코드 작성
security:
oauth2:
client:
registration:
google:
client-id: 발급받은 id
client-secret: 발급받은 비밀번호
scope:
- email
- profile
securityConfig에 추가
여기서 OAuth 로그인 후 후처리는 principalOauth2UserService 여기서 진행하게 됩니다.
http
.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(principalOauth2UserService))
.defaultSuccessUrl("/", true));
DefaultOauth2 UserService을 상속받은 principalOauth2UserService 클래스를 만들고,
구글 로그인 후 값을 잘 가져오는지 콘솔로 찍어 확인해 보려고 합니다.
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration: "+userRequest.getClientRegistration());
System.out.println("getAccessToken: "+userRequest.getAccessToken());
System.out.println("getAttributes: "+super.loadUser(userRequest).getAttributes());
return super.loadUser(userRequest);
}
}
http://localhost:8080/oauth2/authorization/google
위 같은 엔드 포인트로 요청을 보내면 구글 로그인을 할 수 있습니다.
그런데 따로 엔드포인트를 설정한게 없는데 어떻게 아래 엔드포인트로 Oauth 로그인이 될까요?
Spring Security OAuth2의 기본 동작 중 하나는 각 OAuth2 제공자에
대한 로그인 엔드포인트를 자동으로 생성하는 것입니다.
그렇기 때문에 사용자가 해당 엔드포인트로 이동하면 OAuth2 로그인 프로세스가 시작됩니다.
사용자는 해당 제공자의 로그인 페이지에서 로그인을 수행하고,
승인하면 Spring Security가 제공한 콜백 엔드포인트로 다시 리다이렉트 됩니다.
Google의 경우 /login/oauth2/code/google로 리다이렉트 됩니다.
그래서 위에서 승인된 리디렉션 URI로
localhost:8080/login/oauth2/code/google 을 입력한 것입니다.
그렇기에 배포된 환경에서 OAuth 로그인을 하려고 한다면
배포된 도메인도 같이 입력해야지 배포환경에서도 OAuth 로그인을 할 수 있습니다.
실제로 승인된 리디렉션 URI를 등록하지 않는다면
아래와 같이 400 오류 redirect_uri_mismatch 에러가 발생하게 됩니다.
그리고 구글 로그인을 진행해 줍니다.
그러면 아래와 같이 콘솔에 출력되는 로그인 정보들을 확인할 수 있습니다.
이를 자세히 보면 구글로그인을 통해 가져온 정보들임을 알 수 있습니다.
이제 이렇게 가져온 정보들을 통해 로그인을 하고 DB에 저장하여 user로 생성해야 할 것입니다.
getAccessToken: org.springframework.security.oauth2.core.OAuth2AccessToken@171dce06
getAttributes: {sub=109646515849671741701,
name=이유상,
given_name=이유상,
picture=https://lh3.googleusercontent.com/a/ACg8ocIDIAJdY7rkevOPxHTNSErvDt_nHubf3Omnk83p6DW38FM=s96-c,
email=liyusang799@gmail.com,
email_verified=true,
locale=ko}
스프링 시큐리티 세션에 들어갈 수 있는 타입은 Authentication 객체 밖에 없습니다.
Authentication객체 안에 들어갈 수 있는 두 개의 타입은 UserDetails (일반적인 로그인),
Oauth2 User(Oauth를 통한 로그인.. 소셜 로그인)입니다.
controller에서 로그인 상태를 확인할 때 oauth2를 통해 로그인을 한 경우와
일반적인 로그인을 한 경우에 필요한 타입이 다릅니다.
그런데 그 다른 두경우를 나눠서 각각 코드를 작성하는 것은 매우 복잡하고 어려울 것입니다.
그래서 공통의 하나의 클래스를 만들어 UserDetails, Oauth2 User를 implement 해서 사용해야 합니다
기존에 만들었던 PrincipalDetails에 UserDetails만 implement 했지만
OAuth2 User 또한 같이 implement 해주고 필요한 메서드들을 아래와 같이 구현해 줍니다.
@Getter
@AllArgsConstructor
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails, OAuth2User {
@Getter
private User user;
private String username;
private String password;
private Map<String, Object> attributes;
//일반 로그인
public PrincipalDetails(User user) {
this.user = user;
}
//OAuth 로그인
public PrincipalDetails(User user,Map<String,Object> attributes) {
this.user = user;
}
@Override
public <A> A getAttribute(String name) {
return OAuth2User.super.getAttribute(name);
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(user.getAuthority()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public String getEmail() {
return user.getEmail();
}
@Override
public String getName() {
return null;
}
}
OAuth 로그인 후처리를 진행하는 principalOauth2UserService 클래스를 아래와 같이 바꿔봅시다.
구글 로그인 후 필요한 정보들을 가져와
DB를 체크하여 DB에 소셜로그인을 한 기록이 없다면 DB에 필요한 정보들을 저장하고
그렇지 않다면 저장하지 않는 로직입니다.
아까 콘솔로 찍어서 정보를 확인해본 코드들은 이제 필요 없는 코드니 지우시면 됩니다.
@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//System.out.println("getClientRegistration: " + userRequest.getClientRegistration());
//System.out.println("getAccessToken: " + userRequest.getAccessToken());
//System.out.println("getAttributes: " + super.loadUser(userRequest).getAttributes());
OAuth2User oauth2User = super.loadUser(userRequest);
String provider = userRequest.getClientRegistration()
.getRegistrationId(); //google kakao facebook...
String provideId = oauth2User.getAttribute("sub");
String email = oauth2User.getAttribute("email");
String username = oauth2User.getAttribute("name");
String password = "OAuth2"; //Oauth2로 로그인을 해서 패스워드는 의미없음.
String role = "ROLE_USER";
Optional<User> user = userRepository.findByEmail(email);
//이미 소셜로그인을 한적이 있는지 없는지
if (user.isEmpty()) {
User newUser = User.builder()
.email(email)
.username(username)
.password(password)
.phonenumber(null)
.authority(role)
.provider(provider)
.build();
userRepository.save(newUser);
return new PrincipalDetails(newUser, oauth2User.getAttributes());
} else {
return new PrincipalDetails(user.get(), oauth2User.getAttributes());
}
}
}
그리고 SimpleUrlAuthenticationSuccessHandler을 상속받아 OAuth로그인 성공을 처리할 커스텀 클래스를 만들어 줍니다.
로그인 성공시 실행되는 onAuthenticationSuccess을 override 하여
OAuth 로그인이 성공적으로 실행될 시 수행할 작업을 코딩해 줍니다.
저는 기존에 만들어둔 ResponseDTO에 JWT토큰을 생성하여 담아서 프론트쪽에 보낼 것입니다.
주석 부분처럼 처리해서 전달하셔도 됩니다.
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// OAuth2 로그인이 성공했을 때의 추가 작업을 수행
// 여기에서는 JWT 토큰을 발급하고 형식에 맞게 return
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
String token = jwtProvider.createToken(principalDetails.getUser());
/* // 응답 헤더에 JWT 토큰 추가
response.addHeader("Authorization", "Bearer " + token);
// JWT 토큰을 response에 담아서 전송
response.setContentType("application/json");
response.getWriter().write("{\"token\": \"" + token + "\"}");
System.out.println("TOKEN : "+token);
*/
ResponseDTO<Object> loginResponse = ResponseDTO.res(
HttpStatus.valueOf(HttpServletResponse.SC_OK),
"Login successful",
new LoginResponse(principalDetails.getUser().getEmail(),
principalDetails.getUser().getUsername(), token));
ObjectMapper objectMapper = new ObjectMapper();
String jsonResponse = objectMapper.writeValueAsString(loginResponse);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(jsonResponse);
}
}
그리고 spring security 설정에 아래와 같이 성공 시 실행할 클래스를 추가해 줍니다.
방금 만든 커스텀 성공 핸들러를 추가하면 됩니다.
http.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(
userInfoEndpoint -> userInfoEndpoint.userService(principalOauth2UserService))
.successHandler(oAuth2LoginSuccessHandler)
);
이제 다시 스프링을 실행하여 아래의 엔드포인트로 구글 로그인을 진행해 봅시다.
http://localhost:8080/oauth2/authorization/google
구글 로그인을 진행하여...
구글 로그인을 성공적으로 한다면 아래와 같이 JWT토큰을 발급받을 수 있습니다.
발급받은 JWT토큰을 헤더에 넣어 유저 정보를 가져오는 API도 잘 실행이 됩니다!
이후 업데이트
OAuth2LoginSuccessHandler를 다음과 같이 변경하여 프론트단으로 JWT토큰을 쿼리스트링에 담아
리디렉트 되도록 하고 로그인 처리를 하도록 변경했습니다.
이유는 이렇게 발급한 JWT토큰은 프론트단에서 추출하여 사용을 하지 못하기 때문입니다.
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
String token = jwtProvider.createToken(principalDetails.getUser());
String email = principalDetails.getEmail();
String name = principalDetails.getUsername();
//코드 내로 리디렉트 설정
//String redirectUrl = "/user/oauth-success?token="+token;
//한국어 인코딩 설정
String encodedName = URLEncoder.encode(name, StandardCharsets.UTF_8.toString());
String redirectUrl = "프론트도메인?token=" + token
+"&email="+email+"&name="+encodedName;
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
프론트단 코드 - 구글 로그인 버튼 생성
해당 버튼으로 누르면 oauth2/authorization/google로 요청을 하게 됩니다.
import styled from '@emotion/styled';
import { Button } from '@chakra-ui/react';
import NaverIcon from '../../assets/icons/btn_naver.svg';
import GoogleIcon from '../../assets/icons/btn_google.svg';
const SocialLoginContainer = () => {
const handleSocialLogin = (service: string) => {
window.location.href = `https://yanoljaschool.site:8080/oauth2/authorization/${service}`;
};
return (
<StyledContainer>
<StyledButton
backgroundColor="#03C759"
variant="none"
onClick={() => {
handleSocialLogin('naver');
}}
>
<img src={NaverIcon} alt="naver" />
네이버로 시작하기
</StyledButton>
<StyledButton
style={{ color: 'black' }}
onClick={() => {
handleSocialLogin('google');
}}
>
<img src={GoogleIcon} alt="google" style={{ paddingRight: '10px' }} />
구글로 시작하기
</StyledButton>
</StyledContainer>
);
};
export default SocialLoginContainer;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
margin: 5px;
`;
const StyledButton = styled(Button)`
margin: 5px;
color: white;
&:hover {
opacity: 0.9;
}
`;
프론트단으로 리디렉트 되면 아래의 코드처럼 쿼리스트링으로 JWT토큰을 받을 수 있습니다.
그리고 그렇게 받은 JWT토큰을 쿠키에 저장하게 되고 로그인처리를 프론트단에서 진행하게 됩니다.
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { setCookies } from '../../../utils/utils';
import LoadingCircle from '../../../components/Loading';
const SocialLoginLoading = () => {
const navigate = useNavigate();
useEffect(() => {
const handleLogin = async () => {
/* eslint-disable */
const urlParams = new URLSearchParams(location.search);
const token = urlParams.get('token');
const userName = urlParams.get('name');
const userEmail = urlParams.get('email');
if (token && userEmail && userName) {
await setCookies(userEmail, userName, token);
navigate('/');
} else {
alert('로그인에 실패하셨습니다.');
navigate('/login');
}
};
handleLogin();
/* eslint-enable */
}, []);
return <LoadingCircle />;
};
export default SocialLoginLoading;
실제 프론트 배포 페이지에서 버튼을 클릭하면..
아래처럼 구글 로그인창이 뜨게 되고
구글 로그인을 성공하게 되면
JWT토큰을 쿠키에 저장한 채 메인페이지로 가게 됩니다.
OAuth 로그인이 성공적으로 된 것이죠.
내 정보를 확인해 보면 구글 이메일을 통해 로그인이 된 것을 볼 수 있습니다.
전체적인 과정을 정리하자면 아래 사진과 같습니다.
네이버 OAuth 로그인 까지 추가하는 과정을
아래 글에 이어서 작성하겠습니다.
https://yusang.tistory.com/147
'Spring' 카테고리의 다른 글
Spring, OAuth2 + JWT 를 활용하여 소셜 로그인 구현하기 2편 (구글 및 네이버) [Spring 3.1.5, java 17] (0) | 2023.12.11 |
---|---|
spring jpa에 Querydsl 적용하기 [spring 3.1.5, java 17] (0) | 2023.12.06 |
springboot certbot으로 ssl인증서 받아서 https로 배포하기 (1) | 2023.11.30 |
GCP 에서 springboot 프로젝트 docker로 배포하기 (1) | 2023.11.25 |
[Spring] request시 notnull값 controllerAdvice로 처리해서 response보내기 (1) | 2023.11.24 |