이제 본격적으로 구현을 해보자
우선 각 플랫폼 별로 로그인 페이지를 호출하는 코드는 아래와 같다.
// naver
<button onClick={() => "http://localhost:8080/oauth2/authorization/naver"}>네이버</button>
// kakao
<button onClick={() => "http://localhost:8080/oauth2/authorization/kakao"}>카카오</button>
버튼을 누르면 로그인 페이지로 이동이 되고 로그인에 성공을 하면 서버에 로그인 관련 정보를 넣어주고 다시 본인이 구현한 페이지로 리다이렉트 하는 식으로 동작한다.
그럼 제일 먼저 로그인에 성공을 하고 로그인 정보를 가져와 가공을 해주는 부분을 만들어주자
// CustomOAuth2UserService.java
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@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(); // 소셜 정보를 가져옴
OAuth2RequestDto oAuth2RequestDto = OAuth2Attributes.extract(registrationId, originAttributes);
oAuth2RequestDto.setProvider(registrationId);
Member member = saveOrUpdate(oAuth2RequestDto);
// OAuth를 지원하는 소셜 서비스들간의 약속(google=sub, naver=id ...)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName(); // 해당 소셜 서비스에서 유니크한 id값을 전달
Map<String, Object> customAttribute = customAttribute(originAttributes, userNameAttributeName, oAuth2RequestDto, member.getMemberNo(), registrationId);
return new DefaultOAuth2User(
Collections.singleton(
new SimpleGrantedAuthority(member.getRoleKey())),
customAttribute,
userNameAttributeName
);
}
}
로그인을 하고 나서 loadUser 메서드를 통해 OAuth2 정보를 가져온다. 이 안에 로그인 정보가 들어있는 것이다.
이제 안에 들어있는 로그인 속성 정보들을 extract를 통해 가공하여 DTO에 넣어주자 extract 부분은 아래와 같다.
// OAuth2Attributes.java
package com.cac.duduproject.util.oauth;
import com.cac.duduproject.util.oauth.dto.OAuth2RequestDto;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
public enum OAuth2Attributes {
NAVER("naver", (attributes) -> {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
OAuth2RequestDto oAuth2RequestDto = new OAuth2RequestDto();
oAuth2RequestDto.setAttributeCode((String) response.get("id"));
oAuth2RequestDto.setName((String) response.get("nickname"));
return oAuth2RequestDto;
}),
KAKAO("kakao", (attributes) -> {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
OAuth2RequestDto oAuth2RequestDto = new OAuth2RequestDto();
oAuth2RequestDto.setAttributeCode(String.valueOf(attributes.get("id")));
oAuth2RequestDto.setName((String) kakaoProfile.get("nickname"));
return oAuth2RequestDto;
});
private final String registrationId;
private final Function<Map<String, Object>, OAuth2RequestDto> of;
OAuth2Attributes(String registrationId, Function<Map<String, Object>, OAuth2RequestDto> of) {
this.registrationId = registrationId;
this.of = of;
}
public static OAuth2RequestDto extract(String registrationId, Map<String, Object> attributes) {
return Arrays.stream(values())
.filter(provider -> registrationId.equals(provider.registrationId))
.findFirst()
.orElseThrow(IllegalArgumentException::new)
.of.apply(attributes);
}
}
로그인을 한 플랫폼 값마다 각각 다르게 속성 정보를 꺼내서 DTO에 넣어준다.
DTO는 아래와 같이 작성하였다.
// OAuth2RequestDto
package com.cac.duduproject.util.oauth.dto;
import com.cac.duduproject.jpa.domain.member.Member;
import com.cac.duduproject.util.Role;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class OAuth2RequestDto {
private String attributeCode;
private String name;
private String provider;
public Member toOAuth2() {
return Member.builder()
.memberEmail("-")
.memberId("-")
.memberName(name)
.memberPw("-")
.memberGender("-")
.memberBirth("-")
.memberPhone("-")
.role(Role.SOCIAL)
.memberAttributeCode(attributeCode)
.memberProvider(provider)
.build();
}
}
DTO는 간단하다. 나 같은 경우 가져오는 정보가 이름 정도뿐이라 그에 맞게 작성하였다. 로그인을 했을 때 그 플랫폼에서 어떤 정보를 가져와서 어떻게 할 것인지는 개인의 자유이다. 원하는 대로 넣어주고 커스텀해주면 된다.
DTO에 로그인 정보들을 넣어주었다면 이제 자신의 DB에 로그인 정보와 같은 데이터가 있는지 확인을 하고 없다면 회원가입이 진행되고 있다면 정보를 업데이트해주는 식으로 구현해 준다.
// CustomOAuth2UserService.java
// DB에 데이터를 넣어주거나 업데이트하는 과정
private final MemberRepository memberRepository;
private Member saveOrUpdate(OAuth2RequestDto oAuth2RequestDto) {
Member member = memberRepository.findByMemberAttributeCode(oAuth2RequestDto.getAttributeCode())
.map(entity -> entity.oAuthInfoUpdate(oAuth2RequestDto.getName(), oAuth2RequestDto.getProvider()))
.orElse(oAuth2RequestDto.toOAuth2());
return memberRepository.save(member);
}
oAuthInfoUpdate는 해당 JPA 엔티티에 해당 칼럼 값들을 새로 넣어주는 업데이트 형식이다.
// Member.java
@NoArgsConstructor
@Entity
@Getter
@Table(name = "Member")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_no")
private Long memberNo;
@Column(name = "member_name")
@NotBlank
private String memberName;
@Column(name = "member_provider")
@NotBlank
private String memberProvider;
...
public Member oAuthInfoUpdate(String memberName, String memberProvider) {
this.memberName = memberName;
this.memberProvider = memberProvider;
return this;
}
...
}
이제 로그인한 정보와 그에 해당하는 DB 정보를 Map 구조로 저장하여 SuccessHandler로 전해준다.
// CustomOAuth2UserService.java
// Map에 넣어주는 작업
private Map customAttribute(Map attributes, String userNameAttributeName, OAuth2RequestDto oAuth2RequestDto, Long memberNo, String registrationId) {
Map<String, Object> customAttribute = new LinkedHashMap<>();
customAttribute.put(userNameAttributeName, attributes.get(userNameAttributeName));
customAttribute.put("memberNo", memberNo);
customAttribute.put("memberName", oAuth2RequestDto.getName());
customAttribute.put("memberAttributeCode", oAuth2RequestDto.getAttributeCode());
customAttribute.put("provider", registrationId);
return customAttribute;
}
CustomOAuth2UserService.java의 전체코드는 아래와 같다.
// CustomOAuth2UserService.java
@RequiredArgsConstructor
@Service
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); // OAuth2 정보를 가져옴
Map<String, Object> originAttributes = oAuth2User.getAttributes(); // OAuth2User의 attribute
// OAuth2 서비스 ID(google, kakao, naver)
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 소셜 정보를 가져옴
OAuth2RequestDto oAuth2RequestDto = OAuth2Attributes.extract(registrationId, originAttributes);
oAuth2RequestDto.setProvider(registrationId);
Member member = saveOrUpdate(oAuth2RequestDto);
// OAuth를 지원하는 소셜 서비스들간의 약속(google=sub, naver=id ...)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName(); // 해당 소셜 서비스에서 유니크한 id값을 전달
Map<String, Object> customAttribute = customAttribute(originAttributes, userNameAttributeName, oAuth2RequestDto, member.getMemberNo(), registrationId);
return new DefaultOAuth2User(
Collections.singleton(
new SimpleGrantedAuthority(member.getRoleKey())),
customAttribute,
userNameAttributeName
);
}
private Map customAttribute(Map attributes, String userNameAttributeName, OAuth2RequestDto oAuth2RequestDto, Long memberNo, String registrationId) {
Map<String, Object> customAttribute = new LinkedHashMap<>();
customAttribute.put(userNameAttributeName, attributes.get(userNameAttributeName));
customAttribute.put("memberNo", memberNo);
customAttribute.put("memberName", oAuth2RequestDto.getName());
customAttribute.put("memberAttributeCode", oAuth2RequestDto.getAttributeCode());
customAttribute.put("provider", registrationId);
return customAttribute;
}
private Member saveOrUpdate(OAuth2RequestDto oAuth2RequestDto) {
Member member = memberRepository.findByMemberAttributeCode(oAuth2RequestDto.getAttributeCode())
.map(entity -> entity.oAuthInfoUpdate(oAuth2RequestDto.getName(), oAuth2RequestDto.getProvider()))
.orElse(oAuth2RequestDto.toOAuth2());
return memberRepository.save(member);
}
}
SuccessHandler에서 전달받은 DefaultOAuth2User 정보들을 꺼내 JWT 토큰 발급과 로그 기록 등 원하는 대로 작업을 할 수 있게 작성해 주면 된다. 그리고 최종적으로 리다이렉트 할 URL을 지정하여 보내주면 된다.
나는 프론트가 React이기에 localhost:3000으로 전해주었다.
// MyAuthenticationSuccessHandler.java
@RequiredArgsConstructor
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final MemberLogRepository memberLogRepository;
private final RefreshTokenRepository refreshTokenRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) authentication.getPrincipal();
Object memberNo = defaultOAuth2User.getAttributes().get("memberNo");
if(defaultOAuth2User == null) {
throw new NullPointerException("null cannot be cast to non-null type org.springframework.security.oauth2.core.user.OAuth2User");
} else {
try {
if(authentication.isAuthenticated()) {
Member member = memberRepository.findById((Long) memberNo)
.orElseThrow(() -> new IllegalArgumentException("해당 사용자 No가 없습니다. No : " + memberNo));
MemberLog memberLog = MemberLog.builder()
.member(member)
.memberLogType("SOCIAL")
.memberLogSuccessYn("Y")
.memberLogReason("소셜 로그인 인증 성공")
.memberLogIpAddress("")
.build();
memberLogRepository.save(memberLog);
}
JwtTokenResponseDto tokenDto = jwtTokenProvider.generateTokenDto(authentication, "SOCIAL");
// 4. RefreshToken 저장
RefreshToken refreshToken = RefreshToken.builder()
.key(String.valueOf(memberNo))
.value(tokenDto.getRefreshToken())
.build();
refreshTokenRepository.save(refreshToken);
DriverManager.println("SuccessHandler oAuth2User: " + defaultOAuth2User);
response.sendRedirect(UriComponentsBuilder.fromUriString("http://localhost:3000")
.queryParam("bearer", tokenDto.getGrantType())
.queryParam("accessToken", tokenDto.getAccessToken())
.queryParam("refreshToken", tokenDto.getRefreshToken())
.queryParam("expires", tokenDto.getAccessTokenExpires())
.queryParam("expiresDate", tokenDto.getAccessTokenExpiresDate())
.build().encode(StandardCharsets.UTF_8)
.toUriString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
나 같은 경우 JWT 토큰을 프론트로 전해주기만 하면 로그인 관련 로직이 알아서 동작하기에 리다이렉트 정보에 토큰 값들을 넣어주었다. 이는 일반 로그인을 성공했을 때의 로직과 유사하다.
이제 작성한 코드들을 OAuth2를 추가한 Spring Security에 적용을 시켜주자
// SecurityConfig.java
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // Spring Security 활성화 및 웹 보안 설정 구성
public class SecurityConfig {
private final CorsConfig corsConfig;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtTokenProvider jwtTokenProvider;
// 추가!
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final MemberLogRepository memberLogRepository;
private final RefreshTokenRepository refreshTokenRepository;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.cors()
.configurationSource(corsConfig.corsConfigurationSource())
.and()
.formLogin().disable()
.csrf().disable() // rest api 사용시 disable / token을 사용하는 방식일 경우 disable
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
.requestMatchers("/").permitAll()
.requestMatchers("/member/**").permitAll()
.requestMatchers("/lecture/auth/**").permitAll()
.requestMatchers("/faq/**").permitAll()
.requestMatchers("/board/**").permitAll()
.requestMatchers("/review/**").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(jwtTokenProvider))
// 추가!
.and()
.oauth2Login()
.successHandler(new MyAuthenticationSuccessHandler(jwtTokenProvider, memberRepository, memberLogRepository, refreshTokenRepository))
.userInfoEndpoint().userService(customOAuth2UserService);
return httpSecurity.build();
}
}
마지막으로 간편 로그인을 성공하고 리다이렉트를 받을 페이지의 코드는 아래와 같이 작성하였다.
// MainHome.tsx
const MainHome = ():any => {
// 간편로그인 성공 시 생성되는 토큰들을 헤더와 쿠키에 넣어주는 작업
// URL에 데이터가 있기에 꺼내서 사용해주어야 한다.
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
if(searchParams.get("bearer") === "Bearer") {
axios.defaults.headers.common['Authorization'] = `${searchParams.get("bearer")} ${searchParams.get("accessToken")}`;
const expires:string = `${searchParams.get("expires")}`;
const expiresDate:Date = new Date(`${searchParams.get("expiresDate")}`);
setCookie('refreshToken', `${searchParams.get("refreshToken")}`, {
path: '/',
httpOnly: true,
expires
});
...
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Styled.MainHomeView>
...
</Styled.MainHomeView>
)
}
export default MainHome;
리다이렉트로 가져온 정보는 URL로 들어오게 된다. 그래서 useSearchParams를 통해 URL로 들어온 JWT 토큰 정보를 꺼내어 axios 헤더와 쿠키에 각각 넣어주는 작업을 한다.
처음 작업하는 과정에서 OAuth2 메커니즘에 대한 어려움이 있었지만 여러 번 보고 진행을 하다 보니 꽤나 간단한 구조로 인식이 되었고 다음에 다른 방식으로 간편 로그인을 구현하는 일이 생긴다면 보다 수월하게 작업하고 진행할 수 있을 것 같다.
웹사이트를 제작하고자 하면 간편 로그인은 필수이니 꾸준히 공부를 하고 다양한 방법을 탐구해 봐야겠다.
관련 포스팅
2024.03.18 - [[개인프로젝트] 개발 공부] - [프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)
[프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1)
이전에 Spring Security + JWT를 이용해 로그인을 구현해 보았다. 이제 여기에 추가로 간편 로그인 기능도 구현해 보자. 간편 로그인은 사실상 어느 웹사이트를 가든 공통적으로 존재하는 요소이다.
rlawo32.tistory.com
2024.03.01 - [[개인프로젝트] 개발 공부] - [프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)
[프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1)
로그인, 회원가입 개발을 하다 보면 제일 많이 접근하고 자주 구현하게 되는 기능이 아닐까 싶다.예전 막 개발을 시작했을 때는 아무런 기능 없이 회원가입 때 아이디와 비밀번호를 DB에 넣어주
rlawo32.tistory.com
2024.03.06 - [[개인프로젝트] 개발 공부] - [프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2)
[프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2)
이전에 Spring Security 설정을 대략적으로 끝냈다면 이제 JWT를 설정해 보겠다. 우선 JWT dependencies를 작성해 주자 // build.gradle plugins { id 'java' id 'org.springframework.boot' version '3.0.4' id 'io.spring.dependency-manag
rlawo32.tistory.com
2024.03.08 - [[개인프로젝트] 개발 공부] - [프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3)
[프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3)
앞서 Spring Security와 JWT 관련 설정들을 모두 끝냈다면 이제 관련 로직들에 기능들을 적용하여 사용해 보겠다. 로그인 시 JWT 토큰을 발급해 줄 것이고 페이지 새로고침 및 로그인 연장 시 토큰 재
rlawo32.tistory.com
'[개인프로젝트] 개발 공부' 카테고리의 다른 글
[프로젝트] 10. 자동 로그아웃 (feat. 타이머 기능) (0) | 2024.04.03 |
---|---|
[프로젝트] 8. Spring Security + OAuth2.0 (feat. JWT)로 간편 로그인 구현하기 (1) (0) | 2024.03.18 |
[프로젝트] 7. Spring Security + JWT로 로그인 구현하기 (3) (1) | 2024.03.08 |
[프로젝트] 6. Spring Security + JWT로 로그인 구현하기 (2) (3) | 2024.03.06 |
[프로젝트] 5. Spring Security + JWT로 로그인 구현하기 (1) (0) | 2024.03.01 |