Spring Security OAuth2 ,JWT : 모바일/웹 연동 (1)
이글은 Firebase 에서 OAuth2 , JWT 전환기(4) 에서 이어지는 글 입니다.
https://chosunghyun18.tistory.com/176
Firebase 에서 OAuth2 , JWT 전환기(4) : 예외처리
지금까지 FireBase 를 사용한 시나리오에서 OAuth2 , JWT를 사용한 시나리오로 변경한 것은 다음과 같습니다. 1. 사용자는 구글 로그인 버튼이 있는 홈페이지에 접속합니다. - 테스트를 위한 홈페이지
chosunghyun18.tistory.com
필요한 작업의 목록은 다음과 같습니다.
1. OAuth2 로그인이 가능한 Spring 의 내장 정적페이지를 유지한 상태로 mobile 에서 사용할 API 를 개발합니다.
2. 권한별 처리를 만듭니다. 사용자의 분류의 따라 GUEST,USER,ADMIN 으로 나눠져 있으며 적절한 인가처리를 합니다.
3. GUEST 의 정보를 Redis 로 관리합니다. 사용자가 구글로 로그인한뒤 회원가입을 추가로 진행하지 않으면 , 불필요한 데이터가 DB 에 남아 있는것을 처리합니다.
4. 운영에 필요한 기능들을 개발합니다.
1. AccessToken , Refresh Token 만료 기간을 0 으로 만드는 api
2. Refresh Token 을 넘겨주면 AccessToken 을 발급하는 api
OAuth2 로그인이 가능한 Spring 의 내장 정적 페이지를 유지한 상태로 mobile 에서 사용할 RESTAPI 를 개발합니다.
기존의 방식은 모바일에서 구글 클라우드 Auth 와의 통신으로 FireBase 토큰을 받아와 리소스 서버(Spring)으로 전달하는 구조였습니다.
- 클라이언트 자격 증명 방식 (Client Credentials Grant)
권한 코드 승인 방식 (Authorization Code Grant)은 다음과 같습니다.
클라이언트(안드로이드,웹) <-> 리소스 서버(Spring 서버) : 구글 로그인 요청 , 리소스 서버가 리다이렉션 주소를 반환함
클라이언트 -> 인증 서버 (구글 인증 서버,웹) : 리소스 서버의 API 로 로그인 요청
인증 서버 -> 클라이언트 : code , 1 회성 토큰 발급
7. 클라이언트 -> 리소스 서버 : code , 1 회성 토큰 전달
8. 리소스 서버 -> 인증 서버 : 클라이언트에서 받은 code , 1 회성 토큰 전달
9 ~11. 인증 서버 -> 리소스 서버 : 사용자 정보 전달 (payload) 이후 DB 에서 사용자 조회
12. 리소스서버 -> 클라이언트 : 조회된 사용자 정보를 바탕으로 JWT 토큰 발급 & 사용자 정보 반환
-- 스프링 Security 를 사용하는 입장에서 상당부분은 FrameWork 에서 제공하고 있습니다.
지금 현제 프로젝트에서 사용하는 주요 OAuth2 관련 url 은 다음과 같습니다.
스프링에 구글 로그인을 요청하는 url
# request login url : http://localhost:8080/oauth2/authorization/google
구글 로그인이 완료되면 나오는 리다이렉션 url
# success login url : http://localhost:8080/login/oauth2/code/google
클라이언트에서 구글 로그인 url 을 스프링에 요청하 였을때 로그인이 완료되면 , JWT를 건내주는걸 목표로 개발합니다.
기존의 코드는 다음과 같습니다.
소셜 로그인 사이트에서 사용자 정보를 로드 하는 과정에서 사용자가 존재하면 토큰을 갱신하는 코드입니다.
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
private final JwtService jwtService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName,
oAuth2User.getAttributes());
Member member = getByEmail(attributes);
jwtService.tokenUpdateCheck(member);
saveOrUpdate(member);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
public void saveOrUpdate(Member user) {
memberRepository.save(user)
.orElseThrow(() -> new IllegalArgumentException("Save Or Update Error"));
}
public Member getByEmail(OAuthAttributes attributes) {
return memberRepository.findMemberForLogin(attributes.getEmail())
.orElse(attributes.toEntity());
}
로그인이 완료되면 JWT 를 건내주기 위하여 SimpleUrlAuthenticationSuccessHandler 를 상속받은 LoginSuccessHandler 클라스를 추가하겠습니다.
@Slf4j
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
String email = extractUsername(authentication);
jwtService.tokenUpdateCheck(email);
jwtService.addTokensForHttpServletResponse(response, jwtService.getAccessTokenByEmail(email), jwtService.getRefreshTokenByEmail(email));
logAllResponseHeaders(response);
}
private String extractUsername(Authentication authentication) {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
return (String) attributes.get("email");
}
public void logAllResponseHeaders(HttpServletResponse response) {
System.out.println("Logging all response headers:");
for (String headerName : response.getHeaderNames()) {
System.out.print(headerName + ": ");
Collection<String> headerValues = response.getHeaders(headerName);
for (String headerValue : headerValues) {
System.out.print(headerValue + " ");
}
System.out.println();
}
}
}
추가한 Jwt 관련 서비스는 다음과 같습니다.
public void tokenUpdateCheck(String email) {
Member member = memberReadService.findByEmail(email);
if (member.getRole() != Role.GUEST) {
TokenResponse token = new TokenResponse(member.getAccessToken(),
member.getRefreshToken());
token = updateToken(token, member.getEmail());
member.updateToken(token.getAccessToken(), token.getRefreshToken());
}
}
public void addTokensForHttpServletResponse(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.addHeader("AccessToken", accessToken);
response.addHeader("RefreshToken", refreshToken);
}
public String getAccessTokenByEmail(String email) {
Member member = memberReadService.findByEmail(email);
if (member.getRole() != Role.GUEST) return member.getAccessToken();
return "GUESTGUESTGUEST";
}
public String getRefreshTokenByEmail(String email) {
Member member = memberReadService.findByEmail(email);
if (member.getRole() != Role.GUEST) return member.getRefreshToken();
return "GUESTGUESTGUEST";
}
LoginSuccessHandler 도 FilterChain 설정에 등록해 줍니다.
httpSecurity.oauth2Login(request -> request
.successHandler(new LoginSuccessHandler(jwtService))
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
.userService(customOAuth2UserService)));
출력문을 통해 Header 에 임시적으로 토큰이 들어간것을 확인 할 수가 있습니다.
Logging all response headers:
Set-Cookie: JSESSIONID=92DB7EF48027A3FB701129B69A67CCC2; Path=/; HttpOnly
AccessToken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqbzE4NzcxMkBnbWFpbC5jb20iLCJpYXQiOjE2OTczNTM4MDMsImV4cCI6MTY5NzM4OTgwM30.0eaBjGxAfSrPQixGg5pc4oda4RBV8ln0IDQNhN_JaFs
RefreshToken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqbzE4NzcxMkBnbWFpbC5jb20iLCJpYXQiOjE2OTczNTM4MDMsImV4cCI6MTY5NzM4OTgwM30.0eaBjGxAfSrPQixGg5pc4oda4RBV8ln0IDQNhN_JaFs
그러나 http 의 명세의 따라 좋지 않은 접근입니다.
리다이렉트 응답은 보통 HTTP 상태 코드 302와 함께 Location 헤더를 가지며, 대부분의 경우 추가적인 사용자 정의 헤더를 포함하지 않습니다. 그렇므로 다음과 같이 SuccessHandler 를 변경해 줍니다.
@Slf4j
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private static final String REDIRECT_URL = "/";
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String email = extractUsername(authentication);
jwtService.tokenUpdateCheck(email);
getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(email));
}
private String extractUsername(Authentication authentication) {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
return (String) attributes.get("email");
}
private String getRedirectUrl(String email) {
return UriComponentsBuilder.fromUriString(REDIRECT_URL)
.queryParam("Access", jwtService.getAccessTokenByEmail(email))
.queryParam("Refresh", jwtService.getRefreshTokenByEmail(email))
.build().toUriString();
}
}
결과
GUEST 유저인 경우 : 회원 가입을 아직 하지 않은 사용자는 url 에 토큰이 아닌 값을 받습니다.
USER 유저가 로그인한 경우 url 에 토큰을 받습니다.
" URL 에 Access ,Refresh 토큰 값으로 담아 전달하는것에 보안상의 위협이 있을 수 있습니다 "
이러한 상황에서 다음 방향으로 변경을 합니다.
1. 구글 로그인이 완료가 되면 url 을 통하여 임시 JWT 토큰(만료가 매우 짧은 토큰)을 SpringBoot 로 부터 발급 받습니다.
이때 임시 JWT 토큰은 데이터베이스에 따로 저장하지 않습니다.
2. 발급 받은 임시 JWT 토큰을 ResponseBody 의 담아 서버로 로그인 요청을합니다.
3. Authorization 의 토큰을 SpringBoot 가 읽고 Access ,Refresh 토큰을 반환 해 줍니다.
successs handler
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String email = extractUsername(authentication);
jwtService.tokenUpdateCheck(email);
getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(email));
}
private String extractUsername(Authentication authentication) {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
return (String) attributes.get("email");
}
private String getRedirectUrl(String email) {
return UriComponentsBuilder.fromUriString(REDIRECT_URL)
.queryParam("Authorization", jwtService.getAuthTokenByEmail(email))
.build().toUriString();
}
jwtService
public String getAuthTokenByEmail(String email) {
return jwtProvider.createAuthorizationToken(email);
}
jwtProvider
public String createAuthorizationToken(String payload){
long fiveMin = 300000L;
return createToken(payload,fiveMin,AUTH_TOKEN_KEY).get(AUTH_TOKEN_KEY);
}
로그인에 사용할 API
@RequestMapping("/login")
public class OAuth2ApiController {
private final JwtService jwtService;
@PostMapping("/oauth2/token")
public ApiResponse<TokenLoginResponse> mobileGoogleAuthenticationLogin(TokenLoginRequest request) {
String email = jwtService.findEmailByToken(request.token());
String atk = jwtService.getAccessTokenByEmail(email);
String rtk = jwtService.getRefreshTokenByEmail(email);
return ApiResponse.success(new TokenLoginResponse(email,new TokenResponse(atk,rtk)));
}
}
1. 구글 로그인을 진행합니다.
2. url 의 Authorization 의 값을 사용하여 token login 을 합니다.
RestTemplate 을 사용하여 직접 authorization code 를 구글 인증 서버에 전송하여 사용자의 정보를 받아오는 방식이 아닌
SpringSecurity 와 OAuth2 라이브러리의 기능등을 최대한 활용하는 방식으로 개발을 진행해 보았습니다.
배포 확인
로컬에서 테스트를 진행하였으니 EC2 에 배포후 확인을 하는 작업을 합니다.
프로젝트는 Nginx 와 Cerbot 을 사용하여 https 를 지원하고 가비아를 통하여 도메인을 등록 하여 사용하였는데 ,
프로젝트 종료후 전부 삭제를 해둔 상태입니다.
테스트용 페이지 , 현제 IP 주소는 EC2 Spring Boot 의 주소입니다.
구글로그인 버튼을 누르면 공용 IP 가 아닌 LocalHost로 리다이렉션 되는것을 확인 할 수 있습니다.
여기서 localhost 가 아닌 배포한 인스턴스의 주소만 적고 브라우저를 새로고침 해봅니다.
리다이렉션의 문제를 구글 클라우드 설정에 추가해 줍니다.
도메인을 등록해 주고 옵니다.
https://chosunghyun18.tistory.com/178
EC2 가비아 - 도메인 등록 + Nginx & CertBot
ssh 의 연결을 위하여 Certbot 을 사용한 권한 증명을 받는 구성을 합니다. 3 번째로 시스템을 등록 하지만 처음으로 글을 작성해 봅니다. 사전 준비 1. AWS 의 EC2 를 구성합니다. 2. 인바운드 아웃 바
chosunghyun18.tistory.com
다음 그림 처럼 LocalHost에 추가로 등록하지만, 가능하면 url 1개만 등록 합니다.
OAuth2 설정의 redirect-uri 도 변경해줍니다. , 변경을 local에서 안 해주면 localhost 로 리다이렉션 됩니다.
spring:
security:
oauth2:
client:
registration:
google:
redirect-uri: ${Google_Client_REDIRECT_URI}
결과
토큰이 발급되는걸 확인 가능합니다.
guest 이므로 회원 가입
Oauth2 로그인
JWT 토큰 반환
코드 저장소
https://github.com/chosunghyun18/TravelFeelDog-Server
GitHub - chosunghyun18/TravelFeelDog-Server: 여행필독서 앱과 웹을 위한 벡엔드 저장소
여행필독서 앱과 웹을 위한 벡엔드 저장소 . Contribute to chosunghyun18/TravelFeelDog-Server development by creating an account on GitHub.
github.com
참고 블로그 )
리다이렉션 관련 흐름을 그림으로 잘 설명해준 블로그입니다.
https://yelimkim98.tistory.com/45
소셜로그인 2. 구글 콘솔에서 Oauth 앱 생성하기 - 승인된 리다이렉션 URI란
구글 콘솔에서 oauth 앱을 생성해봅시다. 구글 API 콘솔에서 새 프로젝트를 생성합니다. OAuth 클라이언트 ID를 만들어줍니다. OAuth 동의화면부터 채우라고 나오네요. 여기서 필수적인것만 채워주고
yelimkim98.tistory.com