이번주는 OAuth2.0과 SpringSecuirty를 이용한 사용자 로그인을 가지고 왔다.
프로젝트에 이미 써먹고 있지만, 원리부터 과정까지 이해하니 이전에 코드만 이해하고 적용했던 것보다 훨씬 이해가 잘 된다.
다음주는 마지막으로 OAuth 2.0과 Jwt를 이용한 로그인을 공부해보겠다.
실습 목표 및 간단한 동작 원리
실습 목표
Oauth20 클라이언트와 스프링 시큐리티 6 프레임워크를 활용하여 신뢰할 수 있는 외부 사이트로부터 인증을 받고, 전달 받은 유저 데이터를 활용하여 세션을 만들고 인가를 진행
구현
- 인증 : 네이버 소셜 로그인, 구글 소셜 로그인(코드 방식)
- 인가 : 세션 방식을 통한 경로별 접근 권한
- 인증 정보 DB 저장 후 추가 정보 기입
OAuth2.0 인증 방식 및 시큐리티 동작 원리
- 인증 서버와 자원 서버 및 우리의 서버
- 로그인 요청
- → SpringBoot(로그인 페이지 리디렉션 필터)
- → OAuth 인증 서버 로그인 페이지
- → SpringBoot(시큐리티 인증 필터로 코드 발급)
- → OAuth 인증 서버(access token을 얻기 위해 다시 요청)
- → SpringBoot(access token 발급 받음)
- → OAuth 인증 서버(사용자 정보 발급 받기 위해 토큰과 user 정보를 보내줌)
기타
- OAuth2.0 코드 방식 인증 활용
- 인증 후 발급된 정보로 세션을 만들고 SSR 방식으로 모든 페이지를 응답
- 소셜 로그인을 통해 인증 받은 데이터는 우리의 서비스 데이터베이스에 저장한 후 관리 진행
- 관리를 하지 않고 인증만 받은 후 사용할 수 있지만, 추가적인 사용자 정보나 어떠한 사용자가 우리의 서비스를 활용하는지 확인하기 위해 관리하는 것을 권장
프로젝트 생성 및 의존성 추가
기본 컨트롤러 및 VIEW 생성
MainController
package com.practice.oauthsession.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class MainController { @GetMapping("/") public String mainPage() { return "main"; } }
MyController
package com.practice.oauthsession.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class MyController { @GetMapping("/my") public String myPage() { return "my"; } }
main.mustache
my.mustache
동작 원리
코드 방식
- 클라이언트 → 로그인 시도(/oauth2/authorization/naver or google)
- → OAuth2AuthorizationrRequestRedirectFilter
- → 소셜 로그인 인증 서버(해당 서비스 로그인 페이지 응답 → 사용자 로그인)
- → 성공하면 미리 등록한 우리 서비스 특정 경로로 리다이렉트
- → 로그인 성공 리다이렉트 url(/login/oauth2/code/naver or google) + 인증 서버에서 code를 줌
- → OAuth2LoginAuthenticationFilter에서 해당 요청을 받음
- → OAuth2LoginAuthenticationProvider로 code와 정보를 넘겨줌
- → code와 정보를 가지고 인증 서버에 특정 정보와 code를 넘겨줌 → accessToken 받음(코드 소멸)
- → OAuth2LoginAuthenticationProvider에서 소셜 리소스 서버로 accessToken을 보내 유저 정보를 요청함
- → OAuth2UserDetailsService에서 세션 저장과 같은 나머지 시큐리티 로직이 동작됨
각각의 필터가 동작하는 주소(관습)
OAuth2AuthorizationRequestRedirectFilter
/oauth2/authorization/서비스명(naver, google)
OAuth2LoginAuthenticationFilter : 외부 인증 서버에 설정할 redirect url
/login/oauth2/code/서비스명(naver, google)
OAuth2 인증 및 동작을 위한 변수들
변수 설정만 진행 시
- OAuth2AuthorizationRequestRedirectFilter → OAuth2LoginAuthenticationFilter → OAuth2LoginAuthenticationProvider
- 과정을 추가 설정하지 않아도 자동으로 진행
사용자는 UserDetailsService만 구현하면 됨
- application.properties
- spring.security
OAuth2 변수 역할
application.properties
# registartion spring.security.oauth2.client.registration.naver.client-name=서비스명 spring.security.oauth2.client.registration.naver.client-id=서비스에서 발급 받은 아이디 spring.security.oauth2.client.registration.naver.client-secret=서비스에서 발급 받은 비밀번호 spring.security.oauth2.client.registration.naver.redirect-uri=서비스에 등록한 우리쪽 로그인 성공 URI spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.naver.scope=리소스 서버에서 가져올 데이터 범위 # provider spring.security.oauth2.client.provider.naver.authorization-uri=서비스 로그인 창 주소 spring.security.oauth2.client.provider.naver.token-uri=토큰 발급 서버 주소 spring.security.oauth2.client.provider.naver.user-info-uri=사용자 정보 획득 주소 spring.security.oauth2.client.provider.naver.user-name-attribute=응답 데이터 변수
예시
# registartion spring.security.oauth2.client.registration.naver.client-name=naver spring.security.oauth2.client.registration.naver.client-id=서비스에서 발급 받은 아이디 spring.security.oauth2.client.registration.naver.client-secret=서비스에서 발급 받은 비밀번호 spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code spring.security.oauth2.client.registration.naver.scope=name, email # provider spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me spring.security.oauth2.client.provider.naver.user-name-attribute=response
registration은 외부 서비스에서 우리 서비스를 특정하기 위해 등록하는 정보여서 등록이 필수적
하지만, provider의 경우 OAuth2 클라이언트 의존성이 유명한 서비스의 경우 내부적으로 데이터를 가지고 있음
- 즉, 구글은 provider 설정 따로 안해줘도 됨
SecurityConfig 설정
package com.practice.oauthsession.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable());
http
.formLogin((login) -> login.disable());
http
.httpBasic((basic) -> basic.disable());
http
// oauth2Client : 내부를 직접 구현해야 함
// ouath2Login : 필터와 세팅을 자동으로 지정해줌
.oauth2Login(Customizer.withDefaults());
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/oauth/**", "/login/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
네이버 소셜 로그인 신청
- callback URL
- 인증 서버에서 로그인 한 후 code를 받을 주소
- 관습적으로 /login/oauth2/code/서비스명으로 지정함
카카오 소셜 로그인 신청
- REST API 키 : client_id
- Client Secret :
- "보안" 탭으로 이동
- "코드 생성" 버튼 클릭 (아직 생성하지 않은 경우)
- 생성된 코드가 client_secret
OAuth2UserService 응답 받기
OAuth2UserService
DefaultOAuth2UserService 상속
OAuth2UserService의 구현체이기 때문에 상관없음
package com.practice.oauthsession.service; import com.practice.oauthsession.dto.KakaoResponse; import com.practice.oauthsession.dto.NaverResponse; import com.practice.oauthsession.dto.OAuth2Response; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; @Service public class CustomOauth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { // 유저 정보 가져오기 OAuth2User oAuth2User = super.loadUser(userRequest); // 확인용 System.out.println(oAuth2User.getAttributes()); // 네이버나 카카오 혹은 다른 소셜에서 응답이 넘어올 것 // 어디서 온 건지 알려줘야 함 String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuth2Response oAuth2Response = null; // 응답 규격이 달라서 다른 DTO 바구니에 담아야 함 if(registrationId.equals("naver")) { oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); } // 이것도 카카오에 맞춰서 바꾸기 else if(registrationId.equals("kakao")) { oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); } else { } return null; } }
OAuth2Response
소셜 서비스에서 주는 데이터는 서비스마다 다르기 때문에 DTO를 만들어줘야 함
- 네이버 데이터
- 구글 데이터
DTO → 인터페이스 OAuth2Response
NaverResponse
package com.practice.oauthsession.dto; import lombok.RequiredArgsConstructor; import java.util.Map; public class NaverResponse implements OAuth2Response { private final Map<String, Object> attribute; public NaverResponse(Map<String, Object> attribute) { this.attribute = (Map<String, Object>) attribute.get("response"); } @Override public String getProvider() { return "naver"; } @Override public String getProviderId() { return attribute.get("id").toString(); } @Override public String getEmail() { return attribute.get("email").toString(); } @Override public String getName() { return attribute.get("name").toString(); } }
응답 데이터로 로그인 완료
CustomOauth2UserService 나머지 구현
package com.practice.oauthsession.service;
import com.practice.oauthsession.dto.CustomOAuth2User;
import com.practice.oauthsession.dto.KakaoResponse;
import com.practice.oauthsession.dto.NaverResponse;
import com.practice.oauthsession.dto.OAuth2Response;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
public class CustomOauth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 유저 정보 가져오기
OAuth2User oAuth2User = super.loadUser(userRequest);
// 확인용
System.out.println(oAuth2User.getAttributes());
// 네이버나 카카오 혹은 다른 소셜에서 응답이 넘어올 것
// 어디서 온 건지 알려줘야 함
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
// 응답 규격이 달라서 다른 DTO 바구니에 담아야 함
if(registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
// 이것도 카카오에 맞춰서 바꾸기
else if(registrationId.equals("kakao")) {
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
}
else {
return null;
}
String role = "ROLE_USER";
// oAuth2Response에는 role이 없기 때문에 넘겨주어야 함
// CustomOAuth2User를 만들어주어야 함
return new CustomOAuth2User(oAuth2Response, role);
}
}
CustomOauth2User 구현
package com.practice.oauthsession.dto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final OAuth2Response oAuth2Response;
private final String role;
@Override
public Map<String, Object> getAttributes() {
// Attributes는 서비스에서 넘어오는 모든 데이터
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return role;
}
});
return collection;
}
@Override
public String getName() {
// 전달 받은 oAuth2Response에 이름 존재
return oAuth2Response.getName();
}
// Override 하지 않은 메서드 작성
public String userName() {
// ID를 특별하게 만들어줄 것임
return oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
}
}
유저 정보 DB 저장
OAuth2UserService에서 유저 정보를 DB에 저장
- 이미 있는 유저인지 조회
- 없으면 신규 저장
- 있으면 데이터 업데이트 시 업데이트
// 해당 로직 추가
// 여기서 유저 정보 DB 저장 로직 작성
// naver에서는 provider와 providreId를 가지고 ID로 지정
String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
UserEntity existData = userRepository.findByUsername(username);
String role = null;
if(existData == null) {
UserEntity user = new UserEntity();
user.setUsername(username);
user.setEmail(oAuth2Response.getEmail());
user.setRole("ROLE_USER");
role = user.getRole();
userRepository.save(user);
}
else {
role = existData.getRole();
existData.setEmail(oAuth2Response.getEmail());
userRepository.save(existData);
}
커스텀 로그인 페이지
기본 OAuth2 로그인 페이지 존재
커스텀 로그인 페이지 설정
login.mustache
<h1>login page</h1> <hr> <a href="/oauth2/authorization/naver">naver login</a><br> <a href="/oauth2/authorization/google">google login</a> </body>
LoginController
package com.practice.oauthsession.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class LoginController { @GetMapping("/login") public String loginPage() { return "login"; } }
SecurityConfig OAuth2 커스텀 로그인 페이지 등록
SecurityConfig 수정
http // oauth2Client : 내부를 직접 구현해야 함 // ouath2Login : 필터와 세팅을 자동으로 지정해줌 // 유저 엔드포인트 지정 -> 데이터를 받을 수 있는 userDetailsService를 등록해주는 엔드포인트라는 뜻 .oauth2Login((oauth2) -> oauth2 // 커스텀 로그인 페이지 등록 .loginPage("/login") // 요청 엔드포인트 등록 .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOauth2UserService)) );
Client Registration
- 기존 application.properties 변수 설정 파일에서 설정했던 소셜 로그인 제공 서비스에 대한 정보 가입을 관련 클래스를 통해 직접 진행하는 방법
커스텀을 진행하기 위해 클래스를 직접 구현하는 것이 필수
- ClientRegistration
- 서비스 별 OAuth2 클라이언트의 등록 정보를 가지는 클래스
- ClientRegistrationRepository
- ClientRegistration의 저장소, 서비스별 ClientRegistration들을 가짐
application.properties의 oauth2 관련 변수를 모두 주석 처리 후 작성
SocialClientRegistration 작성
package com.practice.oauthsession.oauth2; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.stereotype.Component; @Component public class SocialClientRegistration { public ClientRegistration naverClientRegistration() { return ClientRegistration.withRegistrationId("naver") .clientId("OpD7Da5aVaxvSDQa65PT") .clientSecret("7SGpG0Xu47") .redirectUri("http://localhost:8080/login/oauth2/code/naver") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .scope("name", "email") .authorizationUri("https://nid.naver.com/oauth2.0/authorize") .tokenUri("https://nid.naver.com/oauth2.0/token") .userInfoUri("https://openapi.naver.com/v1/nid/me") .userNameAttributeName("response") .build(); } public ClientRegistration kakaoClientRegistration() { return ClientRegistration.withRegistrationId("kakao") .clientId("bebf13b3e768b4306e7930e69e7aa30e") .redirectUri("http://localhost:8080/login/oauth2/code/kakao") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .scope("profile_nickname") .authorizationUri("https://kauth.kakao.com/oauth/authorize") .tokenUri("https://kauth.kakao.com/oauth/token") .userInfoUri("https://kapi.kakao.com/v2/user/me") .userNameAttributeName("id") .build(); } }
CustomClientRegistartionRepo 작성
package com.practice.oauthsession.oauth2; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; @Configuration @RequiredArgsConstructor public class CustomClientRegistrationRepo { // SocialClientRegistration 의존성 주잊 private final SocialClientRegistration socialClientRegistration; public ClientRegistrationRepository clientRegistrationRepository() { // 두 가지 방식 존재 // 1. 인메모리 저장 // 2. JDBC로 DB에 연결하여 저장 // 여기서는 인메모리로 저장 return new InMemoryClientRegistrationRepository(socialClientRegistration.naverClientRegistration(), socialClientRegistration.kakaoClientRegistration()); } }
securityConfig 수정
http // oauth2Client : 내부를 직접 구현해야 함 // ouath2Login : 필터와 세팅을 자동으로 지정해줌 // 유저 엔드포인트 지정 -> 데이터를 받을 수 있는 userDetailsService를 등록해주는 엔드포인트라는 뜻 .oauth2Login((oauth2) -> oauth2 // 커스텀 로그인 페이지 등록 .loginPage("/login") // 변수 설정이 아닌 커스텀 할 수 있게 레포지토리 등록 .clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository()) // 요청 엔드포인트 등록 .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOauth2UserService)) );
OAuth2AuthorizationRequestRedirectFilter
로그인 페이지에서 GET : /oauth2/authorization 서비스 경로로 요청
- OAuth2 의존성에 의존해 OAuth2AuthorizationRequestRedirectFilter에서 해당 요청을 받음
- 이후 내부 프로세스를 진행
- OAuth2AuthorizationRequestRedirectFilter 요청을 받은 후 해당하는 서비스의 로그인 URI로 요청을 리디렉션
- 이때 서비스의 정보는 ClientRegistrationRepository에서 가져옴
OAuth2AuthorizationRequestRedirectFilter
- 한 번만 실행되는 OncePerRequestFilter를 상속 받아서 한 번만 실행됨
- doFilterInternal을 통해 리디렉션 시킴
OAuth2LoginAuthenticationFilter
OAuth2LoginAuthenticationFilter
- 인증 서버에서 로그인을 성공한 뒤 우리 서버측으로 발급되는 CODE를 획득하고, CODE를 통해 Access 토큰과 User 정보를 획득하는 OAuth2LoginAuthenticationProvider를 호출하는 과정을 시작하는 필터
- attemptAuthentication에서 OAuth2LoginAuthenticationProvider를 호출
'개발' 카테고리의 다른 글
[OAuth2] X(구 트위터) 로그인 API 사용기 (2) | 2024.11.17 |
---|---|
Docker 공부하기 (기초) (1) | 2024.09.01 |
JWT 심화 (1) | 2024.08.11 |
스프링 시큐리티(Spring Security) + JWT (0) | 2024.07.21 |
스프링 시큐리티(Spring Security) 기본 (6) | 2024.07.14 |