오늘 팀원이랑 이야기를 해보다가 우려했던 일이 벌어졌다.. 우려했던 일이란?
Jwt 관련 내용은 다음 글에 있습니다.
Jwt 관련 내용
OAuth2를 Rest API + React 구조로 적용하려고 하니 정보가 너무 없다.
관련 자료는 엄청나게 많은데, 해당하는 자료는 잘못된? 정보가 너무 많거나 구멍 나있는 부분이 너무나 많았다. 그래서 내가 삽질하면서 알아낸 정보들을 정리하고 공유하려고 한다.
간단하게 말해서 회원에 관한 모든 동작(id관리, 비밀번호 관리 등등 회원 쪽 기능)을 타 서비스에 의탁하는 것이다.
OAuth2는 회원의 계정정보를 다른 서비스에 의탁하기 위한 프로토콜이다.
대표적으로 사용되는 서비스로는 Kakao, Google, Facebook, Naver 등이 있다.
오늘은 Google과 Naver로만 구현해볼 것이다!
OAuth2는 서비스의 서버 구조마다 다른 흐름으로 동작한다.
대표적으로 3가지 방식이 있다.
프론트에서 모든 인증 과정을 수행
이러한 방식으로 수행하려는 서비스는 보통 순수 프론트(React)로만 서비스가 수행될 경우를 말한다. 백엔드 서버와의 Http Messaging과정이 존재하지 않는다.
React에서는 Next-Auth라는 라이브러리가 OAuth2의 모든 과정을 수행해주더라..
백엔드에서 모든 인증 과정을 수행
이 방식은 백엔드에서 페이지 관리까지 수행할 경우에 사용한다. RestFul API에는 부적합하다.
프론트 + 백엔드 혼합으로 인증과정을 수행
이 방식은 RestFul API 방식에 적당한 방식이다. 해당 방식으로 구현을 할 예정이다.
흐름을 먼저 그림으로 살펴보자.
그림으로만 보아서 이해가 안된다. 좀 더 상세히 글로 살펴보자.
http://localhost:8080/oauth2/authorization/kakao?redirect_uri=@@@
https://kauth.kakao.com/oauth/authorize
?client_id=
${kakao.clientID} //1
&redirect_uri=${kakao에 등록한 redirectUri} // 2
&response_type=code //3
하나씩 살펴보자.
1번 : 카카오 OAuth2 서비스에 등록한 clientID이다.
2번 : 사용자가 인증을 성공했을 때, 카카오 서비스의 Authorization Code를 전달해주어야 하기 때문에 백엔드의 uri를 입력한다. 이 때, 카카오 서비스는 등록된 redirect uri와 쿼리 파라미터로 넘어온 redirect uri를 비교하고 두 값이 맞으면 해당 uri로 authorization code를 넘겨준다.
3번 : authorization code type이다.
google과 facebook과 같은 해외에서도 많이 쓰이는 서비스들은 주로 spring boot에 이미 설정되어 있다. 하지만 kakao나 naver는 yml에 리다이렉션 uri를 따로 설정해두어야 한다.
https://kauth.kakao.com/oauth/token
token 요청 uri도 kakao나 naver는 yml에 따로 설정해두어야 한다.
8. authorization code가 올바르다면, accessToken이 응답될 것이다.
백엔드 서버는 accessToken을 이용하여 카카오 resource Server에 회원 정보를 요청한다.
accessToken이 올바르다면 카카오 resource Server는 백엔드에게 사용자 정보를 넘겨준다.
백엔드는 사용자 정보를 받고, JWT (access token, refresh token)을 생성한다. (해당 정보로 db의 추가 정보 값을 조회해보고 조회가 안된다면 최초로그인이다.)
백엔드 서버는 해당 토큰을 요청에 포함하여 프론트엔드로 리다이렉트 시킨다. 리다이렉트 시킬때 uri는 2번에서 전달받은 redirect_uri이다.
사실상 요청은 카카오 서버가 했는데 어떻게 프론트로 응답을 할 수가 있나요?
이 때 , 대표적인 방법으로는 따로 로그인 과정 중에 필요한 uri를 약속하고 해당 uri로 리다이렉트 시켜버린다.
맞다. 여기서 언급된 redirect가 2개가 있다.
그럼 스프링 부트에서는 어떻게 해야하나?
먼저, 바로 위에서 이야기 했던 "Redirect URL"을 Spring Boot에서 인식하도록 yml에 필요한 설정을 해야 한다. 설정은 다음과 같다.
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: <your id>
client-secret: <your secret>
redirect-uri: <your url>/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: Kakao
scope:
- profile
- account_email
naver:
client-id: <your id>
client-secret: <your secret>
redirect-uri: <your url>/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
google:
client-id: <your id>
client-secret: your secret>
scope:
- profile
- email
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
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-name-attribute: response
살펴보면 서비스는 총 3가지가 있는 것을 볼 수 있다. (kakao, naver, google)
근데 kakao, naver 두 가지와 google은 내용이 다른 것을 볼 수 있다.
이유는 google은 spring boot에서 자체적으로 등록이 되어있지만, kakao와 naver는 등록되어있지 않아서 추가적인 정보를 입력해준 것이다.
모든 과정은 Spring Security Filter 과정에서 수행된다. 한마디로 Login Controller는 존재하지 않는다.
구현 내용은 정말 많다. 대표적으로 google을 통해서 해보자!
몇몇 부분(controller 부분 및 기타 등등) 구멍난 곳이 많다. 근데 목적만 실행함에 있어서는 무리없다.
아래의 내용은 리액트가 빠졌습니다. 그리고 jwt 생성 후, 프론트를 redirect 시키는 부분은 없습니다.
gradle은 다음과 같다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
SecurityConfig.java 생성
@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler successHandler;
private final TokenService tokenService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/token/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtExceptionFilter(),
OAuth2LoginAuthenticationFilter.class)
.oauth2Login().loginPage("/token/expired")
.successHandler(successHandler)
.userInfoEndpoint().userService(oAuth2UserService);
http.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
}
}
주요 코드를 살펴보자.
OAuth2UserService.java 생성
@Slf4j
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1번
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
// 2번
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
// 3번
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
log.info("registrationId = {}", registrationId);
log.info("userNameAttributeName = {}", userNameAttributeName);
// 4번
OAuth2Attribute oAuth2Attribute =
OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
var memberAttribute = oAuth2Attribute.convertToMap();
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
memberAttribute, "email");
}
}
코드를 살펴보자.
OAuth2Attribute.java 생성
@ToString
@Builder(access = AccessLevel.PRIVATE)
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes;
private String attributeKey;
private String email;
private String name;
private String picture;
static OAuth2Attribute of(String provider, String attributeKey,
Map<String, Object> attributes) {
switch (provider) {
case "google":
return ofGoogle(attributeKey, attributes);
case "kakao":
return ofKakao("email", attributes);
case "naver":
return ofNaver("id", attributes);
default:
throw new RuntimeException();
}
}
private static OAuth2Attribute ofGoogle(String attributeKey,
Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String)attributes.get("picture"))
.attributes(attributes)
.attributeKey(attributeKey)
.build();
}
private static OAuth2Attribute ofKakao(String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String)kakaoProfile.get("profile_image_url"))
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.build();
}
private static OAuth2Attribute ofNaver(String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(response)
.attributeKey(attributeKey)
.build();
}
Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("key", attributeKey);
map.put("name", name);
map.put("email", email);
map.put("picture", picture);
return map;
}
}
해당 클래스는 provider 마다 제공해주는 정보 값들이 다르기 때문에, 분기처리를 위해서 구현한 클래스이다.
google, kakao, naver마다 다른 소스가 동작하도록 구현되어 있다.
OAuth2SuccessHandler.java 생성
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenService tokenService;
private final UserRequestMapper userRequestMapper;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
UserDto userDto = userRequestMapper.toDto(oAuth2User);
log.info("Principal에서 꺼낸 OAuth2User = {}", oAuth2User);
// 최초 로그인이라면 회원가입 처리를 한다.
String targetUrl;
log.info("토큰 발행 시작");
Token token = tokenService.generateToken(userDto.getEmail(), "USER");
log.info("{}", token);
targetUrl = UriComponentsBuilder.fromUriString("/home")
.queryParam("token", "token")
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
Success Handler에 진입했다는 것은, 로그인이 완료되었다는 뜻이다.
이 때가 정말 중요하다.
해당 클래스의 주요 기능은 크게 2가지이다.
원래는 독자적으로 Util을 구현하는게 좋은 방향으로 판단된다. 여기서는 Service에 모두 구현되어있다.
TokenService.java 생성
@Service
public class TokenService{
private String secretKey = "token-secret-key";
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public Token generateToken(String uid, String role) {
long tokenPeriod = 1000L * 60L * 10L;
long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;
Claims claims = Jwts.claims().setSubject(uid);
claims.put("role", role);
Date now = new Date();
return new Token(
"accesstoken",
"refreshToken");
}
public boolean verifyToken(String token) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
return claims.getBody()
.getExpiration()
.after(new Date());
} catch (Exception e) {
return false;
}
}
public String getUid(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
}
임시로 토큰을 "accessToken, refreshToken"으로 생성해두었다.
해당 부분은 설명은 생략하겠다. 코드만 올린다!
Token.java 생성
@ToString
@NoArgsConstructor
@Getter
public class Token {
private String token;
private String refreshToken;
public Token(String token, String refreshToken) {
this.token = token;
this.refreshToken = refreshToken;
}
}
UserDTO는 필요하지만 User는 아직 필요없으니 User는 만들지 않겠다.
UserDTO.java 생성
@NoArgsConstructor
@Getter
public class UserDto {
private String email;
private String name;
private String picture;
@Builder
public UserDto(String email, String name, String picture) {
this.email = email;
this.name = name;
this.picture = picture;
}
}
JwtAuthFilter.java 생성
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
private final TokenService tokenService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = ((HttpServletRequest)request).getHeader("Auth");
if (token != null && tokenService.verifyToken(token)) {
String email = tokenService.getUid(token);
// DB연동을 안했으니 이메일 정보로 유저를 만들어주겠습니다
UserDto userDto = UserDto.builder()
.email(email)
.name("이름이에용")
.picture("프로필 이미지에요").build();
Authentication auth = getAuthentication(userDto);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
public Authentication getAuthentication(UserDto member) {
return new UsernamePasswordAuthenticationToken(member, "",
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
}
}
내용을 보면, jwt의 인증이 성공하면 SecurityContext에 해당 정보를 저장하는 것을 볼 수 있다.
이제 Filter를 등록해보자.
SecurityConfig.java 수정
@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler successHandler;
private final TokenService tokenService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/token/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthFilter(tokenService),
UsernamePasswordAuthenticationFilter.class)
//여기 위에 부분 추가했음!!
.oauth2Login().loginPage("/token/expired")
.successHandler(successHandler)
.userInfoEndpoint().userService(oAuth2UserService);
http.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
}
}
addFilterBefore 부분을 추가했고, 이제 스프링 시큐리티의 UsernamePasswordAuthenticationFilter가 실행되기 전에 JwtAuthFilter가 먼저 실행될 것이다.
완료했다.
이제 url에 다음을 쳐보자.
run하고
http://localhost:8080/oauth2/authorization/google
그러면 로그인 창이 뜰 것이다.
로그인을 하게 되면
accessToken : accessToken,
refreshToken : refreshToken이 정상적으로 응답된 것을 볼 수 있다.
log를 확인하면 Oauth2User에서 받은 user 정보가 log로 찍히는 것을 볼 수 있다.
혹시 전체코드 깃허브 저장소가 있을까요?