OAuth2.0이란?
OAuth2.0 개념
기존에 로그인 방식은 사이트마다 별도로 가입을 해 사용자가 많은 비밀번호를 사용해야 하며, 서비스 제공자 입장에서는 개인 정보를 직접 관리해야하므로 부담이 있었다.OAuth(Open Authorization)
hbb-devlog.tistory.com
클라이언트 입장에서 소셜로그인 기능 구현 시 필요한 oauth2-client 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
위 의존성을 추가 후 네이버 로그인 시 스프링 시큐리티 필터 흐름을 간략하게 살펴보겠다.
- 사용자가 서비스에서 oauth2 로그인 요청 (/oauth2/authorization/naver)
- Oauth2AuthorizationRequestRedirectFilter가 동작하여 네이버 인증 서버로 리다이렉트
- 네이버 인증 서버는 로그인 페이지를 응답
- 로그인 성공 시 네이버 인증 서버는 우리 서비스의 서비스명/login/oauth2/code/naver 경로로 리다이렉트되고 해당 경로에 authorization-code를 넘겨준다.
- 리다이렉트된 요청을 OAuth2LoginAuthenticationFilter가 받아서 OAuth2LoginAuthenticationProvider에게 authorization-code와 yml에 등록 정보 전달
- OAuth2LoginAuthenticationProvider가 위에서 받은 정보를 가지고 네이버 인증 서버에 access-token 발급 요청
- OAuth2LoginAuthenticationProvider가 access-token 얻어온 뒤 해당 토큰을 가지고 네이버 리소스 서버에게 유저 정보를 요청, 획득
- 얻은 유저 정보를 OAuth2UserService가 처리하고 OAuth2User 객체를 생성
- OAuth2User객체를 가지고 Authentication 객체 생성, SecurityContextHolder에 생성한 Authentication 저장
이렇게 oauth2-client 의존성을 추가하면 oauth2 처리와 관련된 필터들이 자동으로 등록된다.
네이버, 구글 소셜 로그인 구현
위 과정을 참고해서 스프링에서 소셜 로그인을 구현해보도록 하겠다.
- application.yml 작성
spring:
security:
oauth2:
client:
registration:
naver:
client-id: ${LOCAL_NAVER_CLIENT_ID}
client-secret: ${LOCAL_NAVER_CLIENT_SECRET}
redirect-uri: ${LOCAL_NAVER_REDIRECT_URI}
authorization-grant-type: authorization_code
scope: name, email
client-name: naver
google:
client-id: ${LOCAL_GOOGLE_CLIENT_ID}
client-secret: ${LOCAL_GOOGLE_CLIENT_SECRET}
redirect-uri: ${LOCAL_GOOGLE_REDIRECT_URI}
authorization-grant-type: authorization_code
scope: profile, email
client-name: google
provider:
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
여기서 registration의 경우 외부 서비스에 우리 서비스를 등록하기 위한 정보로 필수이지만 provider 정보의 경우 naver만 작성하는 것을 볼 수 있다.
이는 oauth2-client가 CommonOAurh2Provider 클래스에 구글, 깃허브, 페이스북 등 세계적으로 사용되는 소셜 로그인의 설정 값을 enum으로 정의했기 때문이다.
public enum CommonOAuth2Provider {
GOOGLE {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.authorizationUri("<https://accounts.google.com/o/oauth2/v2/auth>");
builder.tokenUri("<https://www.googleapis.com/oauth2/v4/token>");
builder.jwkSetUri("<https://www.googleapis.com/oauth2/v3/certs>");
builder.issuerUri("<https://accounts.google.com>");
builder.userInfoUri("<https://www.googleapis.com/oauth2/v3/userinfo>");
builder.userNameAttributeName("sub");
builder.clientName("Google");
return builder;
}
}
...
}
여기서 구글의 scope의 기본은 “openId”, “profile”, “email”이 정의되어 있다.
근데 yml에서 scope를 따로 정의하였는데 이 이유는 openId scope가 있다면 OAuth2LoginAuthenticationProvider 대신 OidcAuthenticationProvider가 동작하여 OidcUser 객체가 생성된다.
네이버의 경우 OIDC를 지원하지 않으며, 네이버와 구글 두 서비스에서 공통적인 OAuth2Service를 사용하기 위해 yml에 등록하였다.
- SecurityConfig 작성
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // Spring Security 설정 활성화
public class SecurityConfig {
private final CustomOAuth2UserService customOauth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(CsrfConfigurer::disable) // CSRF 보호 기능 비활성화
.formLogin(AbstractHttpConfigurer::disable) // 폼로그인 비활성화
.httpBasic(HttpBasicConfigurer::disable); // HTTP Basic 인증 비활성화
http.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint((userInfoEndpointConfig) -> // OAuth2 사용자 정보를 가져올 엔드포인트 설정
userInfoEndpointConfig.userService(customOauth2UserService)) // 사용자 정보를 가져온 상태에서 추가적으로 처리할 기능을 명시
.successHandler(oAuth2LoginSuccessHandler)); // 로그인 성공 시 실행되는 핸들러
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/oauth2/**", "/login/**").permitAll() // 해당 경로만 미인증 사용자들이 요청 가능
.anyRequest().authenticated());
return http.build();
}
}
+ CORS 설정의 경우 아래를 참고한다.
CORS
CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알
hbb-devlog.tistory.com
- OAuth2UserService 작성
@Slf4j
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
log.info("oAuth2User.getAttributes() = {}", oAuth2User.getAttributes());
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 어떤 로그인 서비스를 사용하는지 구분 (Naver or Google)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 진행 시 키가 되는 필드 (구글=sub or 네이버=response)
OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 DTO
Member member = saveOrUpdate(oAuthAttributes);
httpSession.setAttribute("member", new SessionMember(member)); // 세션에 사용자 정보를 지정하기 위한 DTO
//OAuth2User 객체 생성, 반환
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
oAuthAttributes.getAttributes(),
oAuthAttributes.getNameAttributeKey());
}
// 기존 회원이 있는 경우 map()을 통해 최신 정보로 업데이트
// 기존 회원이 없는 경우 orElse()를 통해 새 회원으로 등록
// 한 번 가입한 회원이 다시 로그인할 때 정보를 갱신하면서도 기존 데이터를 유지할 수 있게하기 위함
private Member saveOrUpdate(OAuthAttributes attributes) {
Member member = memberRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getEmail()))
.orElse(attributes.toEntity());
return memberRepository.save(member);
}
}
여기서 oAuth2User.getAttributes()의 값을 보면 네이버와 구글이 다르다.
- Naver Response
- 최상위 필드인 response 내부에 유저 정보가 있다.
{resultcode=00, message=success, response={id=xxx, email=xxx@naver.com, name=hbb}}
- Google Response
- 최상위 필드에 유저 정보가 있다.
{sub=xxx, name=xxx, email=xxx@gmail.com, ...}
- 위 응답에 맞춰 OAuthAttribute Dto 생성
@Getter
@Builder
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if ("naver".equals(registrationId)) {
return ofNaver("id", attributes);
}
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
// 신규 가입 시 OAuthAttributes를 Member Entity로 변환하여 저장하기 위함
public Member toEntity() {
return Member.builder()
.name(name)
.email(email)
.role(Role.GUEST)
.status(DeletionStatus.NOT_DELETE)
.build();
}
}
- 세션에 정보를 저장하기 위한 SessionMember DTO 생성
httpSession.setAttribute("member", new SessionMember(member));
해당 코드에서 member를 세션 저장소에 집어넣지 않고 DTO로 변환하는 이유는 무엇일까?
만약 member를 세션 저장소에 넣는다면 아래와 같은 에러가 나오게 된다.
ConversionFailedException: Failed to convert from type [java.lang.Object] to type [byte[]] for value
이유는 세션에 저장되는 객체는 직렬화가 가능한 클래스이어야 하기 때문이다.
하지만 엔티티의 경우 여러 연관 관계를 맺게 되고 여기서 자식 엔티티를 가지게 된다면 이를 직렬화할 때 자식 엔티티까지 직렬화 되어 사이드 이펙트가 발생할 수 있다.
따라서 직렬화 가능한 SessionMember DTO를 생성하고 이를 세션 저장소에 등록한다.
@Getter
public class SessionMember implements Serializable {
private String name;
private String email;
public SessionMember(Member member) {
this.name = member.getName();
this.email = member.getEmail();
}
}
- 저장소에서 세션 값을 가져오기 위한 애노테이션 생성
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginMember {
}
- 해당 애노테이션을 파라미터로 받기 위한 HandlerMethodArgumentResolver 구현
@RequiredArgsConstructor
@Component
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
// 파라미터가 @LoginMember 타입인지
// 파라미터 클래스 타입이 SessionMember.class 타입인지
// 둘 다 만족하면 해당 애노테이션을 파라미터로 받을 수 있도록 처리함
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean isLoginMemberAnnotation = parameter.getParameterAnnotation(LoginMember.class) != null;
boolean isMemberClass = SessionMember.class.equals(parameter.getParameterType());
return isLoginMemberAnnotation && isMemberClass;
}
// 파라미터로 전달되는 객체
// 세션 저장소에서 로그인한 유저 세션 객체를 가져와서 전달
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return httpSession.getAttribute("member");
}
}
- 구현한 ArgumentResolver를 스프링에서 인식할 수 있도록 등록
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginMemberArgumentResolver loginMemberArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginMemberArgumentResolver);
}
}
- 성공 이후 로직을 처리할 핸들러 구현
@Slf4j
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 Login 성공!");
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
boolean isGuest = oAuth2User.getAuthorities().stream()
.anyMatch(auth -> auth.getAuthority().equals(Role.GUEST.getKey()));
response.sendRedirect(isGuest ? "/signup/additional" : "/main");
}
}
- 세션 동기화
현재 세션은 내장 톰캣의 메모리에 저장된다.
이는 여러 서버가 구동되는 환경에서 세션 상태가 일관되게 유지되지 않는 문제점이 있다.
따라서 DB나 Redis 같은 메모리 DB에 세션 저장소를 사용한다.
여기서는 MySQL DB를 세션 저장소로 사용하도록 하겠다.
→ 요청마다 DB에 접근하므로 성능 이슈가 발생하지만 설정이 간단하므로
spring-session-jdbc 의존성 추가
implementation 'org.springframework.session:spring-session-jdbc'
- 세션 저장소를 JDBC 기반 데이터베이스로 설정
- 애플리케이션이 실행될 때, 데이터베이스 테이블을 자동으로 생성 (운영 시에는 never, 테이블 수동 생성)
spring:
session:
store-type: jdbc
jdbc:
initialize-schema: always
- 수동 생성 시 (MySQL)
CREATE TABLE SPRING_SESSION (
PRIMARY_ID CHAR(36) NOT NULL,
SESSION_ID CHAR(36) NOT NULL,
CREATION_TIME BIGINT NOT NULL,
LAST_ACCESS_TIME BIGINT NOT NULL,
MAX_INACTIVE_INTERVAL INT NOT NULL,
EXPIRY_TIME BIGINT NOT NULL,
PRINCIPAL_NAME VARCHAR(100),
PRIMARY KEY (PRIMARY_ID)
);
CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME);
CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);
CREATE TABLE SPRING_SESSION_ATTRIBUTES (
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
ATTRIBUTE_BYTES BLOB NOT NULL,
PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID)
REFERENCES SPRING_SESSION(PRIMARY_ID) ON DELETE CASCADE
);
- /login에서 로그인 후 테스트
@Slf4j
@RestController
public class TestController {
@GetMapping("/test")
public void test(@LoginMember SessionMember sessionMember) {
log.info("member name = {}", sessionMember.getName());
log.info("member email = {}", sessionMember.getEmail());
}
}
이렇게 하면 로그인 이후 @LoginMember 애노테이션으로 로그인 사용자의 정보를 가져올 수 있다.
'Spring' 카테고리의 다른 글
[Spring] SSE 구현 (0) | 2025.01.15 |
---|---|
[Spring] API 및 예외 응답 통일 (0) | 2025.01.11 |
[Spring] 외부 설정, @Profile (0) | 2024.12.28 |
[Spring] Auto Configuration (0) | 2024.12.28 |
[Spring-boot] JAR, WAR (0) | 2024.12.28 |