나는 새로운 서비스를 이용할 때 매번 새로운 아이디와 비밀번호를 생성하는 것을 별로 좋아하지 않는다. 보통 구글이나 카카오 로그인을 선호한다.
OAuth2는 사용자가 특정 서비스에 직접 회원가입을 하지 않고 접근 권한을 부여 받을 수 있도록 해준다. 이를 통해 애플리케이션의 보안성과 사용자 경험을 향상 시킬 수 있다.
이번에 진행하는 프로젝트에서 Spring Security와 OAuth2.0으로 카카오, 구글, 네이버 로그인 기능을 담당하게 되었다.
3개의 로그인 기능을 구현해야되서 부담스러웠는데, Spring Boot에서 신경 써야할 부분들을 상당 부분 대신 해줘서 개발하기 수월했다.(하지만 시큐리티는 어렵다..)
어떤식으로 구현했는지 정리해본다.
기본적인 Spring Security 설정은 팀원이 담당하였기 때문에 OAuth2에 대한 내용 중심으로 정리했다.
용어 정리
- Resource Owner : 개인 정보의 소유자를 말한다. 서비스 사용자라고 생각하면 쉽다.
- Resource Server : 개인 정보를 저장하고 있는 서버를 의미한다. (구글, 카카오, 네이버)
- Client : Resource Server로부터 인증을 받고자 하는 서버다. 우리가 개발중인 서비스의 서버가 이에 해당된다.
OAuth2 로그인 요청 처리 흐름
- Resource Owner가 Client에 접근하기 위해 Resource Server의 로그인 창을 클릭한다.
- 로그인에 성공하면 Resource Server에 등록해놓은 Redirect URI(http://{Client IP 주소}/login/oauth2/code/{registrationId}?{code} 경로로 요청이 들어온다.
- Client는 {code} 값을 이용해서 다시 Resource Server로 Access Token을 요청한다.
- Client는 Access Token으로 다시 Resource Server로 scope에 해당하는 사용자 정보를 요청한다. (profile, email 등)
- 사용자 정보를 이용해 서비스의 회원인지 아닌지 판단하고 회원이라면 JWT로 Access Token과 Refresh Token을 만들어서 반환하고 웹사이트의 사용자가 아니라면 간단한 회원가입을 진행한다.
- 만약 JWT의 Access Token이 만료된 경우 Refresh Token으로 Resoure Server에게 Access Token의 재발행을 요청한다.
구현
1. 의존성 추가
Spring Security OAuth2 라이브러리와 OAuth2 클라이언트를 사용하기 위한 의존성을 추가했다.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
2. OAuth2 클라이언트 등록
OAuth2 클라이언트를 등록한다.
각 서비스 별로 신규 서비스를 생성해줘야 한다. 여기서 발급된 client-id와 client-sercret을 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있다.
인증 정보는 application.yml에 추가해주었다.
- scope의 기본값은 openId, profile, email이지만 openId가 scope에 있으면 Open Id Provider로 인식하기 때문에 OpenId Provider 서비스(구글)과 아닌 서비스(네이버, 카카오)로 나누어서 OAuth2Service를 만들어야해서 openId를 제외했다.
- OpenId Provider인 서비스의 로그인 기능만 추가할거라면 openId를 넣으면 될 것 같다.
- client-id와 client-secret 데이터는 외부에 노출되면 안되기때문에 jasypt ********암호화했다. ENC(암호) 이게 암호화된 데이터다. (참고 링크)
- Spring Boot 2.0부터 CommonOAuth2Provider가 추가되어 구글, 깃허브, 페이스북, 옥타는 기본 설정값을 스프링이 제공해준다. 카카오, 네이버는 직접 추가해줘야한다..ㅠㅠ
spring:
security:
oauth2:
client:
registration:
google:
client-id: ENC(YROBiwrWocFI0OGZCEDse+j/iHoWN4eO216IVv3+HaZP0tsMPfXIMkJqWGfyYFuty/rb4f7UDi9B/OByAgeNxGQbRgwcC7nHTi/lhasIXaRfW/ETFNQo4g==) # REST API 키
client-secret: ENC(7a9NSzNM7KRlnEW5/UmYcKvcDNrbah0tPTV43EzKUZ1xJi1objqQLUANPHdpp6rv)
scope: profile, email # 기본값이 openid, profile, email이지만 openid를 등록하게 되면 서비스마다(카카오, 네이버) OAuth2Service를 만들어야하기 때문에 profile, email만 scope로 지정
redirect-uri: "<https://66challenge-server.store/login/oauth2/code/google>"
kakao:
client-id: ENC(1HIZn2QzKn4nRunWgWlDbmi6D948IfN/0vuLeoPwwc8oJxlsWOfoU7/K3pOUWG4v) # REST API 키
client-secret: ENC(w/Ip7Zu91manw2kqhhRPO6St73p2GOaOhtigoc+qT4pNbH+FKvuALyd7cq9nv1Zt)
redirect-uri: "<https://66challenge-server.store/login/oauth2/code/kakao>"
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email, gender, age_range # 동의 항목
client-name: Kakao
naver:
client-id: ENC(FuYscwF2S65D3gZ1dFQP2VNW6gd5ZxGs5yqltpIvHTw=) # REST API 키
client-secret: ENC(35dAwGDL/QoXHR0wUz7dxtlrjk6qk8Bq)
redirect-uri: "<https://66challenge-server.store/login/oauth2/code/naver>"
authorization-grant-type: authorization_code
scope: name, email, profile_image, gender, age # 동의 항목
client-name: Naver
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
- Client ID : Resource Server에서 발급해주는 ID이다. 66Challenge에 할당한 ID를 말한다.
- Client Secret : Resource Server에서 발급해주는 비밀번호다. 66Challenge에 할당된 비밀번호를 말한다.
- Authorized Redirect Uri : Client에서 등록하는 Uri다. 이 Uri에서 인증을 요구하는게 아니라면, Resource Server는 해당 요청을 무시한다.
3. 인증 권한 부여 및 사용자 정보 가져오기
OAuth2 인증을 완료 후 전달 받은 데이터로 서비스에 접근할 수 있는 인증 정보를 생성해주고 사용자 정보를 가져온다. Resource Server(구글, 카카오, 네이버)마다 보내주는 데이터가 다르기 때문에 OAuth2Attribute에 전달하여 처리해준다.
loadUser 메서드는 이런 정보가 들어왔는데 회원이 맞는지 확인하는 메서드라고 생각하면 된다. 이 때, OAuth2User를 반환하면 Spring에서 알아서 Session에 저장해준다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final CustomAuthorityUtils authorityUtils;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> service = new DefaultOAuth2UserService();
OAuth2User oAuth2User = service.loadUser(userRequest); // OAuth2 정보를 가져옵니다.
Map<String, Object> originAttributes = oAuth2User.getAttributes(); // OAuth2User의 attribute
// OAuth2 서비스 id (google, kakao, naver)
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 소셜 정보를 가져옵니다.
// OAuthAttributes: OAuth2User의 attribute를 서비스 유형에 맞게 담아줄 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);
User user = saveOrUpdate(attributes);
String email = user.getEmail();
List<GrantedAuthority> authorities = authorityUtils.createAuthorities(email);
return new OAuth2CustomUser(registrationId, originAttributes, authorities, email);
}
/**
* 이미 존재하는 회원이라면 이름과 프로필이미지를 업데이트해줍니다.
* 처음 가입하는 회원이라면 User 테이블을 생성합니다.
**/
private User saveOrUpdate(OAuthAttributes authAttributes) {
User user = userRepository.findByEmail(authAttributes.getEmail())
.map(entity -> entity.update(authAttributes.getName(), authAttributes.getProfileImageUrl()))
.orElse(authAttributes.toEntity());
return userRepository.save(user);
}
}
import challenge.server.user.entity.User;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
import java.util.Map;
@Getter
@ToString
public class OAuthAttributes {
private Map<String, Object> attributes; // OAuth2 반환하는 유저 정보
private String nameAttributesKey;
private String name;
private String email;
private String gender;
private String ageRange;
private String profileImageUrl;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributesKey,
String name, String email, String gender, String ageRange, String profileImageUrl) {
this.attributes = attributes;
this.nameAttributesKey = nameAttributesKey;
this.name = name;
this.email = email;
this.gender = gender;
this.ageRange = ageRange;
this.profileImageUrl = profileImageUrl;
}
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);
} else if ("naver".equals(socialName)) {
return ofNaver("id", attributes);
}
return null;
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name(String.valueOf(attributes.get("name")))
.email(String.valueOf(attributes.get("email")))
.profileImageUrl(String.valueOf(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>) kakaoAccount.get("profile");
return OAuthAttributes.builder()
.name(String.valueOf(kakaoProfile.get("nickname")))
.email(String.valueOf(kakaoAccount.get("email")))
.gender(String.valueOf(kakaoAccount.get("gender")))
.ageRange(String.valueOf(kakaoAccount.get("age_range")))
.profileImageUrl(String.valueOf(kakaoProfile.get("profile_image_url")))
.nameAttributesKey(userNameAttributeName)
.attributes(attributes)
.build();
}
public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name(String.valueOf(response.get("nickname")))
.email(String.valueOf(response.get("email")))
.profileImageUrl(String.valueOf(response.get("profile_image")))
.ageRange((String) response.get("age"))
.gender((String) response.get("gender"))
.attributes(response)
.nameAttributesKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.username(name)
.email(email)
.roles(List.of("USER"))
.build();
}
}
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@AllArgsConstructor
public class OAuth2CustomUser implements OAuth2User, Serializable {
private String registrationId;
private Map<String, Object> attributes;
private List<GrantedAuthority> authorities;
private String email;
@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return this.registrationId;
}
public String getEmail() {
return email;
}
}
4. JWT 토큰 생성
OAuth2MemberSuccessHandler 에서 Access Token과 Refresh Token을 생성한 후 Redirect Uri에 쿼리 파라미터로 담아 보내준다.
import challenge.server.security.jwt.JwtTokenizer;
import challenge.server.security.oauth.dto.OAuth2CustomUser;
import challenge.server.security.utils.CustomAuthorityUtils;
import challenge.server.user.entity.User;
import challenge.server.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Slf4j
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final UserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
OAuth2CustomUser oAuth2User = (OAuth2CustomUser) authentication.getPrincipal();
String email = oAuth2User.getEmail(); // OAuth2User로부터 Resource Owner의 이메일 주소를 얻음 객체로부터
List<String> authorities = authorityUtils.createRoles(email); // 권한 정보 생성
authorities.stream().map(authoritie -> authoritie.replaceFirst("a", "")).collect(Collectors.toList());
redirect(request, response, email, authorities); // Access Token과 Refresh Token을 Frontend에 전달하기 위해 Redirect
}
private void redirect(HttpServletRequest request, HttpServletResponse response, String email, List<String> authorities) throws IOException {
log.info("Token 생성 시작");
String accessToken = delegateAccessToken(email, authorities); // Access Token 생성
String refreshToken = delegateRefreshToken(email); // Refresh Token 생성
User user = userService.findByEmail(email);
Long userId = user.getUserId();
String username = user.getUsername();
user.setRefreshToken(refreshToken);
userService.saveUser(user);
String uri = createURI(accessToken, refreshToken, userId, username).toString(); // Access Token과 Refresh Token을 포함한 URL을 생성
getRedirectStrategy().sendRedirect(request, response, uri); // sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트
}
// Access Token 생성
private String delegateAccessToken(String username, List<String> authorities) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("roles", authorities);
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
// Refresh Token 생성
private String delegateRefreshToken(String username) {
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
// Redirect URI 생성. JWT를 쿼리 파라미터로 담아 전달한다.
private URI createURI(String accessToken, String refreshToken, Long userId, String username) {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("user_id", String.valueOf(userId));
queryParams.add("username", username);
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
return UriComponentsBuilder
.newInstance()
.scheme("https")
.host("66challenge.shop")
.path("/oauth")
.queryParams(queryParams)
.build()
.toUri();
}
}
5. Security 설정
SecurityConfig에 OAuth2 로그인을 처리하기 위한 설정을 추가한다. 인증 및 권한 부여에 대한 설정을 추가한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.oauth2Login(oauth2 -> oauth2
.successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, userService))
.userInfoEndpoint() // oauth2 로그인 성공 후 가져올 때의 설정들
// 소셜로그인 성공 시 후속 조치를 진행할 UserService 인터페이스 구현체 등록
.userService(customOAuth2UserService)); // 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
}
}
결과
'Spring' 카테고리의 다른 글
Spring - Jasypt를 사용해서 application.yml 프로퍼티 암호화하기 (0) | 2023.03.09 |
---|---|
Spring - Spring으로 AWS S3에 이미지 업로드하기2: Spring에서 기능 구현 (0) | 2023.03.06 |
Spring - Scheduler로 매일 자정 실행되는 로직을 짜보자 (0) | 2023.03.02 |
Spring - No Offset 페이지네이션으로 페이징 성능을 개선해보자! (0) | 2023.02.28 |
Spring - 의존관계 주입(DI) 4가지 방법 (0) | 2022.11.30 |