OAuth2 로그인을 구현해보았습니다. 이전까지 구현했던 회원가입과 JWT 로그인도 포함되어 있는데 전체 코드는 GITHUB에서 확인하실 수 있습니다
SecurityContext 에 들어갈 수 있는 객체는 Authentication 타입 뿐입니다. 또한 Authentication 의 Principal 에는 UserDetails 와 OAuth2User 타입만 저장할 수 있습니다.
회원가입 로직을 직접 구현한 경우에는 UserDetails 타입을, 소셜 로그인 구현 시에는 OAuth2User 타입을 저장하게 됩니다.
이전에는 UserpasswordAuthenticationToken 을 만들 때 UserDetailsService 의 loadUserByUsername()
에서 반환된 UserDetails 를 담아주었습니다.
이번에는 OAuth2 를 사용하기 때문에 OAuth2UserService 의 loadUser()
에서 OAuth2User 를 구현한 OAuth2UserImpl 을 생성하여 반환하게 됩니다.
OAuth-Client 라이브러리가 Authorization Code 를 받고, 이를 통해 Access Token 을 받아 소셜에 정보를 요청하고 받는 것을 자동으로 처리해주기 때문에 OAuth2UserService 와 로그인 성공 핸들러만 구현하였습니다.
OAuth-Client 라이브러리가 자동적으로 수행하게 하려면 즉, OAuth2UserService 의 loadUser()
가 자동적으로 호출되게 하려면 redirect URL 을 /login/oauth2/code/소셜타입
으로 설정해주어야합니다.
그렇지 않으면 설정한 redirect URL 뒤에 ?code=XXXX
형식으로 Authorication Code 가 전달되며, OAuth2UserService 가 자동으로 호출되지 않습니다.
OAuth2UserService 는 OAuth 2.0 인증을 통해 소셜로부터 정보를 가져오는 역할을 담당합니다.
loadUser(userRequest)
는 소셜 로그인 API 의 사용자 정보 제공 URI 로 요청을 보내서 사용자 정보를 얻은 후 OAuth2User 를 반환합니다.
즉, OAuth2User 는 OAuth2UserService 가 소셜에서 받은 여러 가지 정보를 담고 있게 되며, getAttributes()
메서드를 통해 소셜로부터 받은 모든 정보를 추출할 수 있습니다.
public class OAuth2UserRequest {
private final ClientRegistration clientRegistration;
private final OAuth2AccessToken accessToken;
private final Map<String, Object> additionalParameters;
...
}
OAuth2UserRequest 에는 내부적으로 AccessToken 을 가지고 있습니다.
Authorization Code 를 이용해 Access Token 받아 이를 통해 사용자에 대한 정보를 요청하고 받는 과정을 처리할 때 사용됩니다.
OAuth-Client 가 자동으로 처리해주기 때문에 직접적으로 사용하지는 않습니다.
loadUser()
의 결과로 반환되는 객체가 OAuth2User 입니다. 즉, 소셜 사이트로부터 전달받은 정보를 가지고 있는 객체가 바로 OAuth2User 가 되는 것입니다.
getAttributes()
를 통해 Map 을 추출할 수있는데, 아래와 같은 내용을 가지고 있습니다.
각 소셜 사이트 별로 Map 의 형태와 사용자에 대한 정보가 담긴 key 가 달라서 소셜 별로 다르게 정보를 추출해야합니다.
< google > { "sub": "구글에서 나의 식별값", "name": "name", "given_name": "given_name", ... }
< naver > { "resultcode": "00", "message": "success", "response": { "email": "email", ... } }
@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
SocialType socialType = OAuth2Utils.getSocialType(registrationId);
...
}
}
OAuth2UserService 를 구현한 구현체의 loadUser()
메서드가 실행되면 부모인 DefaultOAuth2UserService 의 loadUser()
를 호출하여 소셜로부터 정보를 받아 OAuth2User 를 생성합니다.
OAuth2User 에서 추출한 registrationId 는 어떤 소셜에서 정보를 제공해주었는지, userNameAttributeName 은 OAuth2User 가 가진 전체 정보에서 사용자 정보가 담긴 key를 나타냅니다.
registrationId=naver userNameAttributeName=response
Naver 로그인을 했기 때문에 registrationId 는 naver 가 출력되었고, Naver 의 경우 response 라는 key 안에 JSON 형식으로 사용자에 대한 정보가 들어있기 때문에 userNameAttributeName 는 response 를 출력하게 됩니다.
각 소셜을 구분하기 위해 SocialType 이라는 Enum 타입을 만들었는데, 전달받은 registrationId 를 통해 SocialType 을 반환받기 위해 OAuth2Utils 의 getSocialType()
을 호출합니다.
public class OAuth2Utils {
// registrationID 를 보고 어떤 소셜에서 인증을 했는지 반환
public static SocialType getSocialType(String registrationId) {
// Member 에서도 사용하기 위해 대문자로 변경
if (registrationId != null) {
registrationId = registrationId.toUpperCase();
}
if ("GOOGLE".equals(registrationId)) {
return SocialType.GOOGLE;
} else if ("KAKAO".equals(registrationId)) {
return SocialType.KAKAO;
} else if ("NAVER".equals(registrationId)) {
return SocialType.NAVER;
}
return null;
}
...
}
OAuth2Utils 는 각 소셜마다 분기 처리를 진행하는 메서드를 총 집합해놓은 클래스 입니다.
만약 각 클래스에서 직접 분기 처리를 진행한다고 했을 때 소셜 로그인이 추가되거나 필요한 정보가 바뀌면 각 클래스들을 돌아다니면서 수정해야합니다.
이를 방지하고자 분기 처리가 필요한 모든 메서드들을 하나의 클래스에서 처리해줄 수 있도록 위 클래스를 만들게 되었습니다.
getSocialType()
은 String 을 전달받아 SocialType 을 반환해주는 메서드입니다.
registrationId 를 통해 SocialType 을 구할 때와 OAuth2 최초 로그인 시 회원가입을 할 때 Member 객체를 DB 에 저장하는 과정에서 String 을 SocialType 으로 변환할 때 사용됩니다.
@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
// 소셜에서 전달받은 정보를 가진 OAuth2User 에서 Map 을 추출하여 OAuth2Attribute 를 생성
Map<String, Object> attributes = oAuth2User.getAttributes();
// 내부에서 OAuth2UserInfo 생성과 함께 OAuth2Attributes 를 생성해서 반환
OAuth2Attributes oauth2Attributes = OAuth2Attributes.of(socialType, userNameAttributeName, attributes);
...
}
}
OAuth2User 의 getAttributes()
를 통해 Map 을 추출합니다.
이 Map 에는 유저 정보를 포함한 다른 모든 정보가 담겨 있고, 그 형태는 소셜마다 조금씩 다르며, 네이버의 경우 아래처럼 되어 있습니다.
{ "resultcode": "00", "message": "success", "response": { "email": "email", ... } }
그 후에 각 소셜로부터 받은 정보 중에서 사용자 정보 추출에 필요한 key 와 필요한 정보를 가진 객체인 OAuth2Attributes 를 생성합니다.
@Getter
public class OAuth2Attributes {
private String nameAttributeKey; // OAuth2 로그인 시 사용자 정보가 담긴 key 값 ( ex> 네이버 = response )
private OAuth2UserInfo oauth2UserInfo;
@Builder
public OAuth2Attributes(String nameAttributeKey, OAuth2UserInfo oauth2UserInfo) {
this.nameAttributeKey = nameAttributeKey;
this.oauth2UserInfo = oauth2UserInfo;
}
// OAuth2Utils 를 통해 분기처리 없이 생성된 OAuth2UserInfo 를 반환
public static OAuth2Attributes of(SocialType socialType, String userNameAttributeName,
Map<String, Object> attributes) {
return OAuth2Attributes.builder()
.nameAttributeKey(userNameAttributeName)
.oauth2UserInfo(OAuth2Utils.getOAuth2UserInfo(socialType, attributes))
.build();
}
}
각 소셜마다 정보를 주는 방법이 다르기 때문에 각 소셜마다 분기 처리를 하여 OAuth2Attribute 를 만드는 메서드를 다르게 정의하고, 그 내부에서 서로 다른 방식으로 각 정보들을 파싱해야합니다.
이를 위해 소셜로부터 받은 정보를 전달받고, 필요한 정보들을 추출하는 메서드를 가진 OAuth2UserInfo 라는 추상클래스를 만들고, 소셜마다 이를 구현한 구현체를 만들었습니다.
OAuth2Attribute 는 OAuth2UserInfo 부모 클래스에만 의존하기 때문에 분기 처리 없이 동일한 코드를 사용하게 되고, 필요한 정보나 소셜 로그인이 추가되어도 생겨도 코드를 수정하지 않아도 됩니다.
public class OAuth2Utils {
...
// 소셜로부터 받은 정보들을 받아 파싱하는 OAuth2UserInfo 를 생성하는 메서드
public static OAuth2UserInfo getOAuth2UserInfo(SocialType socialType, Map<String, Object> attributes) {
switch (socialType) {
case GOOGLE:
return new GoogleOAuth2UserInfo(attributes);
case NAVER:
return new NaverOAuth2UserInfo(attributes);
case KAKAO:
return new KakaoOAuth2UserInfo(attributes);
}
return null;
}
}
이번에도 OAuth2Utils 에서 분기처리를 진행하여 각 소셜 타입에 맞게 OAuth2UserInfo 를 상속한 구현체들을 생성하여 반환하는 역할을 수행합니다.
각 구현체에서 필요한 정보를 추출하기 위해 소셜로부터 받은 attributes 를 함께 전달합니다.
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes; // 각 소셜에서 전달된 전체 정보
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
// 소셜에서 제공받은 정보를 추출하기 위한 메서드 ( 필요한 정보 )
public abstract String getSocialId(); // 소셜 식별 값 ( 소셜 내부에서 사용자 식별값 )
public abstract String getEmail(); // 최초 로그인 시, 회원가입할 때 이메일 전달을 위해 사용
}
회원가입, 로그인을 위해 socialId 와 email 정보가 필요하기 때문에 이 두 가지 정보를 추출하는 추상 메서드를 정의하고, 각 소셜 별로 추상 클래스와 추상 메서드를 구현하게 됩니다.
생성자에서 전달받은 attributes 라는 Map 은 소셜로부터 받은 전체 정보를 의미하고, 거기서 유저에 대한 정보를 추출하여 Map 으로 만들게 됩니다.
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public static Map<String, Object> responseMap; // 소셜로부터 받은 정보( attributes ) 에서 유저 정보들이 담긴 Map
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
responseMap = (Map<String, Object>) attributes.get("response"); // attributes 에서 유저에 대한 정보만 추출하여 Map 으로 생성
}
// Naver 는 정보가 attributes 내부에 response 라는 key 의 value 로, value 내부에 ( key, value ) 형식으로 담겨져서 온다
@Override
public String getSocialId() {
return String.valueOf(responseMap.get("id"));
}
@Override
public String getEmail() {
return String.valueOf(responseMap.get("email"));
}
}
OAuth2Attributes 에서 사용자 정보가 담긴 key 값을 의미하는 nameAttributeKey 을 받아도 되지만 구글의 경우 이를 사용하지 않을 뿐만 아니라 카카오도 이메일을 추출하기 위해서 필요한 key 가 또 있기 때문에 직접 키를 입력하도록 구현하였습니다.
@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
// Member 생성을 위한 정보를 가지고 있는 OAuth2UserInfo
OAuth2UserInfo oauth2UserInfo = oauth2Attributes.getOauth2UserInfo();
String socialId = oauth2UserInfo.getSocialId();
String email = oauth2UserInfo.getEmail();
// 소셜 타입과 소셜 ID 로 조회된다면 이전에 로그인을 한 유저
// DB 에 조회되지 않는다면 Role 을 GUEST 로 설정하여 반환 => LoginSuccessHandler 에서 회원가입으로 리다이렉트 후 추가 정보를 받는다
Member member = memberRepository.findBySocialTypeAndSocialId(socialType, socialId)
.orElse(Member.builder().email(email).role(MemberRole.GUEST).socialType(socialType).socialId(socialId).build());
return new OAuth2UserImpl(Collections.singleton(new SimpleGrantedAuthority(member.getRole().getValue())),
attributes, oauth2Attributes.getNameAttributeKey(), member);
}
}
Member 객체를 찾기 위해 OAuth2Attributes 가 가진 OAuth2UserInfo 를 가져오고, OAuth2UserInfo 에서 필요한 socialId 와 email 을 추출합니다.
SocialId 는 각 소셜 내부에서 사용자를 구분하는 값 정도로 생각하면 됩니다. 그렇기 때문에 SocialType 과 SocialId 로 사용자를 구분하면 절대 일치하지 않는 고유한 값이 됩니다.
socialId 와 socialType 으로 DB 에서 사용자를 조회하는데 만약 사용자가 없다면 회원가입을 위해 필요한 정보들만으로 이루어진 Member 객체를 생성해서 전달하게 됩니다.
회원가입 시 email, socialId, socialType 을 저장하기 위해 role 을 GUEST 로 하여 Member 객체를 만들고, 필요한 모든 정보들로 OAuth2UserImpl 객체를 만들어서 반환하게 됩니다.
public class OAuth2UserImpl extends DefaultOAuth2User {
Member member; // 토큰 만들기 위해 Member 객체를 담아서 전달
public OAuth2UserImpl(Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes, String nameAttributeKey,
Member member) {
super(authorities, attributes, nameAttributeKey);
this.member = member;
}
}
OAuth2UserService 의 loadUser()
에서 반환되는 OAuth2User 객체에서 추가해야 할 필드가 있기 때문에 이를 상속받은 OAuth2UserImpl 을 구현합니다.
저는 이전에 토큰을 만드는 JwtUtils 에서 Member 를 전달받고, member 내부에서 id 와 role 값을 꺼내 사용했기 때문에 OAuth2UserImpl 에 Member 를 넣었습니다.
OAuth2UserService 부모의 loadUser()
를 통해 OAuth2User 를 반환받고, OAuth2UserService 가 OAuth2AuthenticationProvider 에게 반환할 때는 OAuth2UserImpl 을 반환하게 됩니다.
@RequiredArgsConstructor
public class CustomOAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Member member = ((OAuth2UserImpl) authentication.getPrincipal()).getMember();
String accessToken = JwtUtils.generateAccessToken(member);
// 최초 로그인인 경우 추가 정보 입력을 위한 회원가입 페이지로 리다이렉트
if (member.getRole().equals(MemberRole.GUEST)) {
response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
String redirectURL = UriComponentsBuilder.fromUriString("http://localhost:8080/oauth2/signUp")
.queryParam("email", member.getEmail())
.queryParam("socialType", member.getSocialType())
.queryParam("socialId", member.getSocialId())
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
getRedirectStrategy().sendRedirect(request, response, redirectURL);
} else {
String refreshToken = JwtUtils.generateRefreshToken(member);
jwtService.save(new RefreshToken(refreshToken, member.getId()));
response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
response.addHeader(JwtConstants.REFRESH, JwtConstants.JWT_TYPE + refreshToken);
// 최초 로그인이 아닌 경우 로그인 성공 페이지로 이동
String redirectURL = UriComponentsBuilder.fromUriString("http://localhost:8080/loginSuccess")
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
getRedirectStrategy().sendRedirect(request, response, redirectURL);
}
}
}
로그인이 성공하면 실행되는 성공 핸들러입니다. Authentication 에서 OAuth2UserImpl 를 꺼내고, 그 내부에서 Member 객체를 꺼내서 진행합니다.
최초 로그인인 경우 memebr 의 role 을 확인하고, member 에 담긴 email, socialId, socialType 을 쿼리 파라미터로 하여 회원가입 페이지로 리다이렉트 시킵니다.
만약 최초 로그인이 아니라면 로그인 성공 페이지로 이동하게 됩니다.
@Controller
public class OAuth2Controller {
// OAuth2 로그인 시 최초 로그인인 경우 회원가입 진행, 필요한 정보를 쿼리 파라미터로 받는다
@GetMapping("/oauth2/signUp")
public String loadOAuthSignUp(@RequestParam String email, @RequestParam String socialType, @RequestParam String socialId, Model model) {
model.addAttribute("email", email);
model.addAttribute("socialType", socialType);
model.addAttribute("socialId", socialId);
return "member/signUp";
}
}
리다이렉트 시 호출되는 컨트롤러입니다. 쿼리 파라미터로 전달된 값들을 @RequestParam
을 통해 받게 되고 이를 Model 에 담아 회원가입 페이지로 전달하게 됩니다.
<div>
<label for="name">이메일</label>
<!-- model 을 통해 email 이 전달되면 input 에 표시 및 readOnly 설정 -->
<!-- 단> 카카오는 email 이 전달되지 않아서 빈값도 확인 -->
<input type="text" id="email" class="form-control" th:value="${email}"
th:readonly="${email != null && email != '' ? 'readonly': false}">
<p id="error-email" style="color: red"/>
</div>
<!-- OAuth2 최초 로그인 후 회원가입 시, socialType, socialId 정보를 DB 에 기록해야함 -->
<div>
<input type="text" id="socialType" class="form-control" th:value="${socialType}" hidden="hidden">
<input type="text" id="socialId" class="form-control" th:value="${socialId}" hidden="hidden">
</div>
화면에서 주의할 점은 위의 두 가지 입니다. 회원가입 시 form 의 데이터를 json 으로 만들어 controller 에게 넘겨주기 때문에 socialId 와 socialType 을 받아야 하며, 사용자가 입력하지 않도록 hidden 처리를 합니다.
또한 이메일의 경우, 소셜 로그인을 통해 이메일이 전달된 경우라면 입력 폼을 readOnly 로 설정하였습니다. 다만 카카오의 경우 null 은 아니지만 빈 값이 오기 때문에 이에 대한 처리도 진행해주었습니다.
참고로 readOnly 가 아닌 disabled 로 설정하게 되면 서버로 데이터가 넘어가지 않는다고 합니다.
public class Member {
...
// Member 가 생성되기 이전 DTO 로 User 를 생성할 때 사용
// 비밀번호 암호화까지 동시에 수행
public static Member createUser(MemberDTO dto, PasswordEncoder passwordEncoder) {
Member member = Member.builder()
.id(dto.getId())
.name(dto.getName())
.email(dto.getEmail())
.password(passwordEncoder.encode(dto.getPassword())) // 암호화해서 User 생성
.role(MemberRole.USER) // 역할 지정
.socialType(OAuth2Utils.getSocialType(dto.getSocialType())) // String 형태의 SocialType 을 Enum 타입으로 변경
.socialId(dto.getSocialId())
.build();
return member;
}
}
화면에서 전달받은 JSON 을 DTO 에 담아서 받은 후 이를 Entity 로 변환하는 메서드입니다.
여기서 새롭게 Member 객체를 만들고 DB 에 저장해주기 때문에 OAuth2UserServiceImpl 에서는 Member 객체를 생성만 하고 저장하지 않았습니다.
Member 의 SocialType 은 Enum 인데 DTO 에서 SocialType 이 String 으로 넘어오기 때문에 OAuth2Utils 를 통해 이를 Enum 타입으로 변환합니다.
회원가입 시 ID, 비밀번호, 이름, 이메일을 입력합니다.
로그인 시 ID 와 비밀번호를 입력하고, 로그인이 성공한다면 alert 창과 함께 로그인 성공 페이지로 이동합니다.
로그인 성공을 확인하기 위해 페이지 접근 권한을 체크하지 않았습니다.
OAuth 2.0 을 통한 최초 로그인 시 회원가입 페이지로 이동합니다.
이때 소셜로부터 받은 이메일 정보는 자동으로 form 에 들어가게 되고, readOnly 로 설정되어 수정할 수 없습니다.
OAuth 2.0 로그인 시 로그인이 성공한다면 로그인 성공 페이지로 이동합니다.
Postman 을 이용한 로그인 시도입니다. 로그인이 성공하면 Response Header 에 Access Token 과 Refresh Token 을 전달받게 됩니다.
전달받은 Access Token 으로 /user
요청을 하게 되면 user 입니다 라는 메시지가 출력됩니다.
User 가 /admin
요청을 하게 되면 접근 권한이 없다는 메세지가 출력됩니다.
admin 권한을 가진 아이디로 로그인 후 /admin
요청을 하게 되면 정상적으로 접근됩니다.
Access Token 이 만료되면 /renew
를 통해 Refresh Token 을 전달하여 새롭게 Access Token 을 받을 수 있습니다.
안녕하세요. 상세한 설명으로 덕분에 초보자인 저도 잘 이해했습니다! 감사합니다:)
글을 읽던 도중에 궁금한 점이 생겨서 댓글 남깁니다.
OAuth 2.0 최초 로그인에 성공하고 회원가입 페이지로 넘어가서 비밀번호를 입력하는 이유가 있을까요?