[USports] Member OAuth2
Member를 만들고, 간편 인증이 필요할 것 같아 OAuth2를 사용하였다
- 그 중에 카카오 간편 로그인을 먼저 사용했다
OAuth란?
항상 간편 로그인을 위해서 사용되는 프로토콜인 줄 알았다
OAuth를 공부하면서 느낀 것
- Resource Server, 즉 카카오나 네이버 같이 많은 사람들이 이미 회원가입을 한 어플리케이션에서, Client, 내 어플리케이션에게 자신들의 자원을 사용할 수 있는 권한을 주는 것이라고 크게 느꼈다
- Resource Owner : 자원을 소유하는 사람 (예. 유저들)
- Resource Server : Resource Owner의 정보가 저장되어 있는 서버 (예. 카카오, 네이버)
- Client : Resource Server에 저장되어 있는 Resource Owner의 정보를 사용하려고 하는 어플리케이션 (예. 내가 만드는 어플리케이션)
과정
- 사용자가 카카오 간편 로그인을 시도한다
- 카카오 (Resource Server)에서는 카카오 로그인 창을 띄어주거나, 유저가 해당 어플리케이션에 첫 로그인이면, 회원 동의 창을 띄어준다
- 로그인을 하거나, 회원 동의를 완료했으면 카카오에서는 어플리케이션 (client)에게 인가 코드 (Authorization Code)를 발급해 준다
- 인가 코드 같은 경우, 해당 회원에 대해, Resource Server에 있는 자원을 사용할 수 있다는 것을 1차적으로 확인
- 인가 코드를 통해 Client는 Resource Server에게 Access Token을 요청하고, 발급을 받는다
- 이 Access Token을 통해서 Client는 Resource Server에 저장되어 있는 Resource Owner의 자원을 사용할 수 있게 된다
여기서 자원이란 것은, 유저의 이메일, 나이 같은 자원도 얘기하지만, 그 외에, 구글 캘린더, 메세징 같은Resource Server에서 제공되는 다른 기능들도 사용할 수 있게 된다 (단, Resource Server로 부터 먼저 동의를 받아야 한다)
내가 생각하고 만든 Member 가입 또는 로그인 구조
- 일반
- 로그인 창에서 ID와 Password를 입력한다
- DB와 비교해서, ID가 있고, Password가 일치하면 JWT access token을 제공하여, 서비스를 사용할 수 있도록 권한을 준다
- 카카오
- 인가 코드를 받는다 (이 때, redirect_uri도 같이 입력해야 한다)
- 그리고 인가 코드를 통해 access token을 받는다
- 그 토큰을 통해 해당 유저에 대한 데이터를 가지고 온다
- Email가 서비스 DB에 있는지 없는지 확인하고, 없으면 자동으로 회원 가입을 시킨다
- 있으면 바로 JWT access token을 발급하여 서비스를 사용할 수 있도록 권한을 준다
위와 같이 생각하면 굳이 OAuth2를 사용하지 않아도 된다고 생각했다
하지만, OAuth2 코드를 보고, 사용하지 않을 때의 코드를 보니, OAuth2를 사용하면 나중에 추가로 resource server를 사용할 때에, 더 쉽게 간편 로그인을 구축할 수 있다고 느꼈다
'확장성'을 생각하면 OAuth2를 사용하는 것이 편하다는 생각을 했다
코드 구현
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
- 추가로 thymeleaf와 spring-boot-starter-web을 가져와서, 간편 로그인이 잘 되는지 확인을 했다
application.properties
- 개인적으로 redirect-uri 가 제일 중요했던 것 같다
- /login/oauth2/code 는 OAuth2 에서 제공하는 Default redirect-uri다
- 다른 uri로 넣었더니, 제대로 작동하지 않았다 ㅜ.ㅜ
- 그리고 google, github 같은 해외 resource server 같은 경우 provider를 추가할 필요가 없다
- 이 뜻은, Kakao, Naver는 provider를 추가를 안 하면, OAuth가 작동이 안 된다
spring.security.oauth2.client.registration.kakao.client-id={client id}
spring.security.oauth2.client.registration.kakao.client-secret={client secret}
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, account_email, gender
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
MemberDto (MemberEntity의 DTO)
- OAuth2User 를 implement 하면 getAttributes() 라는 메서드를 오버라이드 해야한다
- 여기서 getAttributes() 는 OAuth2를 통해서, resource server에서 가지고 온 자원의 속성을 리턴해준다
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberDto implements UserDetails, OAuth2User {
private Long memberId;
private String email;
private String password;
private LocalDateTime registeredAt;
private LocalDateTime updatedAt;
private LocalDateTime emailAuthAt;
private boolean profileOpen;
private Role role;
private LoginBy loginBy;
private Map<String, Object> attributes;
public static MemberDto fromEntity(MemberEntity memberEntity){
return MemberDto.builder()
.memberId(memberEntity.getMemberId())
.email(memberEntity.getEmail())
.password(memberEntity.getPassword())
.registeredAt(memberEntity.getRegisteredAt())
.updatedAt(memberEntity.getUpdatedAt())
.emailAuthAt(memberEntity.getEmailAuthAt())
.role(memberEntity.getRole())
.loginBy(memberEntity.getLoginBy())
.build();
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> auth = new ArrayList<>();
auth.add(new SimpleGrantedAuthority("ROLE_" + this.role));
return auth;
}
@Override
public String getUsername() {
return this.accountName;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
CustomOAuth2MemberService
String registrationId = userRequest.getClientRegistration().getRegistrationId();
- 이 부분을 통해서 resource server가 어딘지 문자열로 구분지어 준다
OAuth2MemberInfo memberInfo = checkResourceServer(registrationId, oAuth2User);
- resource server마다 값을 리턴해줄 때에, 형태가 다 다르다
- 예를 들어 kakao에는 "kakao_account"에 속성을 넣어뒀다
- 그래서 밑으로 가면 따로 KakaoMemberInfo를 만들어서, 필요한 값을 빼올 수 있는 클래스를 만들었다
- resource server마다 값을 리턴해줄 때에, 형태가 다 다르다
Optional<MemberEntity> member = memberRepository.findByEmail(email);
- 만약 email을 통해서 맴버 엔티티를 가지고 오지 못 하면, resource server에서 가지고 온 정보를 DB에 저장한다
- 반대로, 맴버 엔티티를 찾으면, 로그인을 할 수 있도록 해준다
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2MemberService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 서비스 구분을 위한 작업 (여기서 어느 서비스와 연동되는지 나옴. kakao 또는 naver 등)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
log.info("{}", registrationId);
OAuth2MemberInfo memberInfo = checkResourceServer(registrationId, oAuth2User);
String provider = memberInfo.getProvider();
String providerId = memberInfo.getProviderId();
String nickName = memberInfo.getNickName();
String email = memberInfo.getEmail();
Optional<MemberEntity> member = memberRepository.findByEmail(email);
MemberEntity memberEntity = null;
if (member.isEmpty()) {
memberEntity = memberRepository.save(
MemberEntity.builder()
.email(email)
.emailAuthAt(LocalDateTime.now())
.password(passwordEncoder.encode(UUID.randomUUID().toString().substring(0, 10)))
.profileOpen(false)
.loginBy(LoginBy.KAKAO)
.role(Role.UNAUTH)
.build());
} else {
memberEntity = member.get();
}
MemberDto memberDto = MemberDto.fromEntity(memberEntity);
memberDto.setAttributes(oAuth2User.getAttributes());
return memberDto;
}
private OAuth2MemberInfo checkResourceServer(String registrationId, OAuth2User oAuth2User) {
OAuth2MemberInfo memberInfo = null;
if (registrationId.equals("kakao")) {
memberInfo = new KakaoMemberInfo(oAuth2User.getAttributes());
log.info("{}",oAuth2User.getAttributes());
}
return memberInfo;
}
}
KakaoMemberInfo
- OAuth2MemberInfo 라는 인터페이스를 만들었다
- Kakao 뿐만 아닌, 다른 간편 로그인을 사용할 수 있어, 확장성을 위해 인터페이스를 만들었다
public class KakaoMemberInfo implements OAuth2MemberInfo{
private Map<String, Object> attributes;
private Map<String, Object> kakaoAccountAttributes;
private Map<String, Object> profileAttributes;
public KakaoMemberInfo(Map<String, Object> attributes) {
this.attributes = attributes;
this.kakaoAccountAttributes = (Map<String, Object>) attributes.get("kakao_account");
this.profileAttributes = (Map<String, Object>) kakaoAccountAttributes.get("profile");
}
@Override
public String getProviderId() {
return attributes.get("id").toString();
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getNickName() {
return profileAttributes.get("nickname").toString();
}
@Override
public String getEmail() {
return kakaoAccountAttributes.get("email").toString();
}
@Override
public String getGender() {
return kakaoAccountAttributes.get("gender").toString();
}
}
SecurityConfig
- 가장 애를 먹었던 부분이었던 것 같다
- OAuth2 부분만 보면된다
- 로그인 페이지는 /login으로 시작하고, 만들어 놓은 CustomOAuth2MemberService를 실행한다
- 해당 부분에서 성공을 하게 되면 OAuth2SuccessHandler에 들어간다
- 해당 클래스는 JWT 토큰을 유저에게 생성해주는 메서드가 들어간 클래스다
- successHandler에서는 redirect 하는 페이지로 request와 response를 같이 가져간다
- 그래서 successHandler로 redirect 할 때에, redirect한 페이지에 로그인한 정보가 포함되어 있다
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 어노테이션으로 권한 부여
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter authenticationFilter;
private final CustomOAuth2MemberService customOAuth2MemberService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.cors().and()
.headers().frameOptions().disable();
http
.authorizeRequests()
.antMatchers("/member/**").permitAll();
http
.oauth2Login().loginPage("/login")
.userInfoEndpoint().userService(customOAuth2MemberService)
.and().successHandler(oAuth2SuccessHandler);
http
.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
OAuth2SuccessHandler
- Access Token과 Refresh Token을 생성하고, Refresh Token은 redis에 저장을 한다
- 원래는 /oauth2/login/success 로 redirect하면, 그 url에서 토큰을 생성하여, 쿠키에 저장했다
- redirect 같은 경우, 성공을 했을 시에, redirect한 url에 성공된 유저 정보를 모두 포함해서 가지고 간다
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
MemberDto memberDto = (MemberDto) authentication.getPrincipal();
log.info("{}", memberDto.getAttributes());
log.info("OAuth2 인증 성공");
TokenDto token = tokenProvider.saveTokenInRedis(memberDto.getEmail());
getRedirectStrategy().sendRedirect(request, response,
UrlConstant.USPORTS_URL + "/oauth2/login/success")
}
}
'프로젝트 > USports' 카테고리의 다른 글
[USports] Websocket 구현 (1) | 2023.12.25 |
---|---|
[USports] Websocket 소개 (0) | 2023.12.18 |
[USports] Member 단위 테스트 (1) | 2023.12.07 |
[USports] Email 전송 (1) | 2023.12.06 |
[USports] Login 구현 (1) | 2023.12.05 |