이번에는 스프링 시큐리티, OAuth2(구글, 카카오), Jwt (Access Token, Refresh Token) 까지 모두 이용하여 소셜로그인을 구현해보자!!
제3의 서비스에 계정 관리를 맡기는 방식
인증 서버에서 발급받은 토큰을 이용하여 리소스 서버에 리소스 오너의 정보를 요청하고, 인증서버에게 인증을 받고, 응답받아 사용할 수 있다.
클라이언트(스프링부트 서버)가 사용자 데이터에 접근하기 위해 권한 서버(카카오,구글)에 요청을 보내는 것.
클라이언트 ID, 리다이렉트 URI, 응답 타입 등을 파라미터로 보내 요청한다.
실제 요청 예시)
GET spring-auhorization-server.example/authorize?
client_id=48a4893g
redirect_uri=http://localhost:8080/myapp&
response_type=code&
scope=profile
인증 서버에 요청을 처음 보내는 경우 사용자에게 보이는 페이지를 로그인 페이지로 변경하고 사용자 데이터에 접근 동의를 얻는다.(최초 1회). 이후에는 로그인만 진행한다. 로그인이 성공하면 권한 부여 서버는 데이터에 접근할 수 있게 인증 및 권한 부여를 수신한다.
사용자가 로그인에 성공하면 권한 요청시 파라미터로 보낸 redirect_uri로 리다이렉션한다.
이때 파라미터에 인증 코드를 함께 제공한다.
GET http://localhost:8080/myapp?code=a2sdf38s
인증 코드를 받으면 액세스 토큰으로 교환해야 한다!
액세스 토큰은 로그인 세션에 대한 보안 자격을 증명하는 식별 코드.
POST spring-auhorization-server.example/token {
"client_id" :"48a4893g"
"client_secret" : "shuefh",
"redirect_uri" : "http://localhost:8080/myapp"
"grant_type" : "authorization_code",
"code" : "siuefhufei1"
}
//토큰 응답 값
{
"access_token": "siufeh",
"token_type" : "Bearer",
"expires_in" : 3600,
"scope" : "openid profile"
}
제공받은 엑세스 토큰으로 리소스 오너의 정보를 가져온다. 정보가 필요할 때마다 API호출을 통해 정보를 가져오고, 리소스 서버는 액세스 토큰이 유효한지 검사 후 응답한다.
//리소스 오너의 정보를 가져오기 위한 요청
GET spring-authorization-resource-server.example.com/userinfo
Header : Authorization: Bearer aasdffb
이렇게 Userinfo를 받아올 수 있다. 그럼 이제 실제로 구현을 해보자!!!!!!!!!!
spring:
datasource:
url: jdbc:mysql://localhost:3306/[db이름]?serverTimezone=Asia/Seoul
username: root
password: 11111111
data:
redis:
host: localhost
database: 0
port: 6379
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
open-in-view: true
hibernate:
ddl-auto: update
# OAuth2 설정
security:
oauth2:
client:
registration:
google:
client-id:
client-secret:
scope:
- profile
- email
kakao:
client-id:
client-secret:
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: kakao
scope:
- profile_nickname
- account_email
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
#jwt 비밀키
jwt:
secret_key: springboot-jwt
먼저 yml 설정을 시작한다. DB는 Mysql, RefreshToken을 저장하기 위한 NoSql은 Redis를 사용하였다!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtils jwtUtils;
private final CustomOAuth2UserService customOAuth2UserService;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberService memberService;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
@Bean
public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository
() {
return new OAuth2AuthorizationRequestBasedOnCookieRepository();
}
@Bean
public OAuth2SuccessHandler oAuth2SuccessHandler() {
return new OAuth2SuccessHandler(jwtUtils,
refreshTokenRepository, memberService, oAuth2AuthorizationRequestBasedOnCookieRepository());
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.addFilterBefore(new JwtAuthenticationFilter(jwtUtils), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception.accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint))
.oauth2Login(oauth2 ->
oauth2.authorizationEndpoint(authorization -> authorization.authorizationRequestRepository(
oAuth2AuthorizationRequestBasedOnCookieRepository()))
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler()))
.build();
}
}
본격적으로 코드를 짜기 전에, SecurityConfig의 filterChain을 살펴보자. oAuth2와 Jwt를 이용할 것이기 때문에 httpBasic, formLogin은 비활성화하고, session을 사용하지 않을 것이기 때문에 STATELESS로 설정해준다.
authoirizeHttpRequests : 나는 api/**주소로 오는 요청은 인증이 필요하도록 설정했고, 나머지 요청에 대해서는 permit으로 두었다. 나중에 Role 권한도 추가하면서 애플리케이션에 맞게 지정해주면된다.addFilterBefore : Jwt 토큰 인증을 위한 JwtAuthenticationFilter를 추가한다.exceptionHandling : accessDeniedHander는 인증된 사용자이지만 ROLE 권한이 없을 때를 위한 핸들러, authenticationEntryPoint는 인증되지 않은 사용자가 인증이 필요한 요청을 했을 때 에러 처리를 도와준다.oauth2Login: oauth2 로그인에 관한 정보들을 설정한다.autorizationEndpoint: 자격 증명을 얻기 위한 엔트포인트로, Authorization request와 관련된 state(redirect-uri, client-id.. 등)를 저장하기 위해 oAuth2AuthorizationRequestBasedOnCookieRepository를 사용한다. 저장하는 이유는 oauth2 로그인 프로세스가 여러번의 요청으로 진행이 되기 때문에, 이 요청정보를 유지하여 프로세스를 처리하기 위해서이다. 스프링 시큐리티가 이 클래스를 사용하여 쿠키에 저장된 인증 요청 정보를 가져와 인증프로세스를 진행할 때 필요한 데이터를 제공받는다.customOAuth2UserService : 소셜 accessToken을 통해 UserInfo를 받아와 UserDetails를 생성하는 서비스이다. 이것까지 완료가 됐다면, successHandler로 이동한다.oAuth2SuccessHandler: 생성한 UserDetails Authentication을 통해 Jwt token을 생성하고, 리다이렉트를 진행한다.그럼 이제 필요한 클래스들을 생성해보자!
refreshToken을 쿠키에 저장해 보낼 것이기 때문에, 쿠키관련 Util을 설정해준다.
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.setPath("/");
cookie.setMaxAge(0);
cookie.setValue("");
response.addCookie(cookie);
}
}
}
//객체를 직렬화해 쿠키 값으로 변환
public static String serialize(Object obj) {
return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize((Serializable) obj));
}
//쿠키를 역직렬화 해 객체로 변환
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
인증 요청 정보 request를 저장하기 위한 레포지토리를 생성한다.
/*
인증 요청 정보 http request를 쿠키에 저장, 불러오기 ,삭제
*/
public class OAuth2AuthorizationRequestBasedOnCookieRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
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);
}
그전에 서로 다른 소셜 로그인을 처리하기 위한 OAuthAttributes 클래스를 생성해보자!~
@Getter
@ToString
@Builder
public class OAuthAttributes {
private Map<String, Object> attributes; //oAuth2 반환하는 유저의 정보
private String nameAttributesKey;
private String name;
private String email;
private String profileImage;
//social name에 따라 userinfo를 얻어옴
public static OAuthAttributes of(String socialName, Map<String, Object> attributes) {
if ("kakao".equals(socialName))
return ofKakao("id", attributes);
else if ("google".equals(socialName))
return ofGoogle("sub", attributes);
return null;
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.profileImage((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributesKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) attributes.get("profile");
return OAuthAttributes.builder()
.email(String.valueOf(kakaoAccount.get("email")))
.name(String.valueOf(kakaoProfile.get("nickname")))
.profileImage(String.valueOf(kakaoProfile.get("profile_image_url")))
.nameAttributesKey(userNameAttributeName)
.attributes(attributes)
.build();
}
public Member toEntity(String registrationId) {
return Member.builder()
.name(name)
.email(email)
.image(profileImage)
.role(Role.USER)
.socialType(SocialType.valueOf(registrationId))
.build();
}
}
내가 소셜로그인에서 얻어올 정보는 사용자 이름, 사용자의 이메일, 그리고 사용자의 프로필 사진이기 때문에 3개의 필드를 작성하였다.
ofGoogle : 구글이 userinfo를 제공하는 리턴값에 맞추어 해당 필드들을 채워준다.ofKakao : 카카오가 userinfo를 제공하는 리턴값에 맞추어 해당 필드들을 채워준다. 카카오는 kakaoAccount 하위에 kakaoProfile이라는 attributes가 또 있기 때문에 유의해서 가져오면 된다.toEntity : 해당 애트리뷰트를 통해 Member entity를 생성한다.@Service
@Transactional
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
//유저의 정보를 받아오는 함수
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> service = new DefaultOAuth2UserService();
OAuth2User oAuth2User = service.loadUser(userRequest);
Map<String, Object> originAttributes = oAuth2User.getAttributes();
//oAuth2 서비스 id (google, kakao)
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //소셜 정보 가져오기
//attribute를 서비스 유형에 맞게 담음
OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);
Member member = saveOrUpdate(registrationId, attributes);
Set<GrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(member.getRole().toString()));
return new DefaultOAuth2User(authorities, attributes.getAttributes(), attributes.getNameAttributesKey());
}
//이미 존재하는 회원이라면 이름과 프로필 사진 업데이트,
//처음 가입한다면 DB에 생성
private Member saveOrUpdate(String registrationId, OAuthAttributes attributes) {
Member member = memberRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getProfileImage()))
.orElse(attributes.toEntity(registrationId.toUpperCase()));
return memberRepository.save(member);
}
}
loadUser를 통해 유저의 정보를 받아와 DefaultOAuth2User를 반환한다. attributes를 서비스 유형에 맞게 담고, saveOrUpdate함수를 통해, 이미 존재하는 회원이라면 이름과 프로필 사진을 업데이트시키고, 처음 가입한다면 DB에 생성하도록 한다. 여기서 나는 사용자의 Email로만 비교를 하기 때문에, 소셜타입이 달라도 이메일이 같으면 같은 사용자로 취급이 되게 되어있다. 따라서 이메일이같아도 소셜타입이 다르면 다른 사용자로 로그인 되게 하려면 추가적인 로직이 필요하다!
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String REFRESH_TOKEN = "refresh_token";
private static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
private static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(30);
private final JwtUtils jwtUtils;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberService memberService;
private final OAuth2AuthorizationRequestBasedOnCookieRepository
oAuth2AuthorizationRequestBasedOnCookieRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Member member = memberService.findByEmail((String) oAuth2User.getAttributes().get("email"));
//리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
String refreshToken = jwtUtils.generateToken(member, REFRESH_TOKEN_DURATION);
saveRefreshToken(member.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
//액세스 토큰 생성
String accessToken = jwtUtils.generateToken(member, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
//인증 관련 설정값, 쿠키를 제거
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
//생성된 리프레시 토큰을 db에 저장
private void saveRefreshToken(Long memberId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByMemberId(memberId)
.map(entity -> entity.update(newRefreshToken))
.orElse(new RefreshToken(memberId, 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);
CookieUtil.addCookie(response, REFRESH_TOKEN, refreshToken, cookieMaxAge);
}
//인증 관련 설정값, 쿠키 제거
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
oAuth2AuthorizationRequestBasedOnCookieRepository.removeAuthorizationRequestCookies(request, response);
}
//액세스 토큰을 패스에 추가
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString("/")
.queryParam("token", token)
.build().toString();
}
}
onAuthenticationSuccess : 이 메소드를 오버라이드 하여 oauth2Login 성공 시 해야할 일들을 실행해준다. oAuth2User를 통해 member를 찾고, refreshToken을 생성하여 쿠키에 저장한다. 물론 redis DB에도 저장해준다.accessToken도 생성한다. accessToken은 targetUrl에 추가하여 param으로 전달해준다.clearAuthenticationAttributes를 통해 인증 관련 설정값과 쿠키를 제거해준 후, targetUrl로 redirect를 보내준다. 프론트와 통신을 할 것이라면 redirect를 프론트쪽으로 넘겨주면 된다.
로그인을 진행하면,
http://localhost:8080/?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MTAxMzM0NjQsImV4cCI6MTcxMDEzMzUyNCwic3ViIjoidG5hbHMyMzg0QGdtYWlsLmNvbSIsImlkIjoxfQ.8y627jGX2sc9YBnZAdkV_ACs4NMSOgiSQWRcQhrWsVk

이와 같은 경로로 리다이렉트 되고, 파라미터로 accessToken이, 쿠키로 refreshToken이 전달되게 된다.
프론트에서는 이 token을 저장해두고, accessToken은 요청할때 헤더에 담아 요청하면 된다. 만약 accessToken의 유효기간이 끝나 더이상 요청할 수 없다면, 새로운 accessToken을 발급받는 요청을 refreshToken과 함께 보내면 된다. 그러면 이제 헤더의 accessToken을 확인하여 인증을 진행하는 JwtAuthenticationFilter를 살펴보자.
JWT Util과 redis를 통한 refreshToken은 여기를 참고하자.
스프링 시큐리티 + JWT 적용하기
스프링부트에서 Redis를 사용해보자!
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtils.getJwtFromHeader(req);
//가져온 토큰이 유효한지 확인
if(tokenValue != null) {
if (jwtUtils.validateToken(tokenValue)) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = jwtUtils.getAuthentication(tokenValue);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
}
filterChain.doFilter(req, res);
}
}
이 filter에서는, 헤더의 accessToken을 통해 authentication을 부여하여 인증이 필요한 요청을 수행할 수 있도록 해준다.
먼저 헤더의 토큰을 가져온 다음, 토큰이 비어있지 않다면 가져온 토큰이 유효한 토큰인지 확인 후, jwt 토큰을 통해 우리 DB의 Member 정보와 비교하여 인증 정보를 생성하여 SecurityContextHolder에 저장한다.
만약 이 필터에서 멤버정보로 authentication이 생성되지 않고, 인증되지않은 사용자로 남게 된다면, securityChain의 authenticationEntryPoint로 이동하게 된다.
이렇게 JWT와 OAUTH를 이용하여 로그인을 구현해봤다! 정말 어려웠지만 많은 공부가 되었다..
하지만 프론트와 직접 연동하기 위해서는 더 공부가 필요할 것도 같다. 다음엔 WebClient를 통해 소셜로그인을 처리하는 방법도 공부해보자!