스프링 부트 3.x 버전부터 SpringSecurity OAuth2 설정 방법이 바뀌게 되었다...
이 책을 보면서 공부하겠다고 마음을 먹은 것도 이 부분이 가장 컸다!!
(책에서는 스프링 부트 2.x 이므로 설정 방법이 바뀌게 되었고 부트 3.x와 더 친해지기 위해 공부하고 싶어졌다!! 필자는 3.2.0 버전을 썼다)
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
가장 크게 달라진 부분이 2가지 있었다.
1. SecurityConfig의 extends WebSecurityConfigurerAdapter 금지
→ Springboot 3.x부터는 @Bean 형태로 작성해야한다.
https://sennieworld.tistory.com/109
2. .and() 문법 금지
→ 무분별한 .and() 문법 대신 람다 문법을 사용하도록 바뀌었다고 한다.
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.headers(header -> header
.frameOptions(frameOptions -> frameOptions.disable()))
.authorizeRequests(authorizeRequests -> authorizeRequests
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated())
.logout((logoutConfig) ->
logoutConfig.logoutSuccessUrl("/"))
.oauth2Login(oauth2Login -> oauth2Login
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile");
}
1. @EnableWebSecurity : Spring Security 설정들 활성화
2. csrf() : h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
3. authorizeRequests
URL별 권한 관리를 설정하는 옵션의 시작점
authorizeRequests가 선언되어야만 antMatchers 옵션 사용 가능 (antMatchers → requestMatchers)
4. (antMatchers → requestMatchers)
권한 관리 대상을 지정하는 옵션
URL, HTTP 메서드별로 관리 가능
"/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 준다.
"/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.
5. anyRequest
설정된 값들 이외 나머지 URL들을 나타낸다.
.authenticated()을 추가하여 나머지 URl들은 모두 인증된 사용자들에게만 허용
인증된 사용자 즉, 로그인한 사용자
6. logout().logoutSuccessURL("/")
로그아웃 기능에 대한 여러 설정의 진입점
로그아웃 성공 시 / 주소로 이동
7. oauth2Login
OAuth 2 로그인 기능에 대한 여러 설정의 진입점
8. userinfoEndpoint
OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
9. userService
소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
리소스 서버(소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시 가능
CustomOAuth2UserService : 로그인 이후 가져온 사용자의 정보들을 기반으로 가입, 정보수정, 세션 저장 등의 기능 지원
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final HttpSession httpSession;
@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();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName,
oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
1. registrationId
현재 로그인 진행 중인 서비스 구분 코드
구글, 네이버, 카카오 등 어떤 소셜 로그인인지 구분하기 위해 사용
2. userNameAttributeName
OAuth2 로그인 진행시 키가 되는 필드값. (Primary Key 유사)
구글의 경우 기본적으로 코드("sub") 지원, 네이버/카카오 등은 기본 지원이 없음.
3. OAuthAttributes
OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
네이버 등 다른 소셜 로그인도 이 클래스 사용
4. SessionUser
세션에 사용자 정보를 저장하기 위한 Dto 클래스
세션에 저장하기 위해 User 클래스를 세션에 저장할 경우 User 클래스에 직렬화를 구현하지 않아서 에러 발생!!
그럼 User를 직렬화시켜서 넣는다면?? - 문제 : 엔티티 오염!!
OAuthAttributes :
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email,
String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
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"))
.picture((String) response.get("picture"))
.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"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
SessionUser : 인증된 사용자 정보만 필요
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
Login 관련 mustache
...
<h1>스프링 부트로 시작하는 웹 서비스 Ver.2</h1>
<div class="col-md-12">
<!-- 로그인 기능 영역 -->
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 동록</a>
{{#userName}}
Logged in as : <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/userName}}
</div>
</div>
<div class="row">
<div class="col-md-6">
<a href="/posts/save" role="button"
class="btn btun-primary">글 등록</a>
</div>
</div>
<br>
<!-- 목록 출력 영역-->
...
어노테이션 기반 개선
같은 코드의 반복
Session user = (SessionUser) httpSession.getAttribute("user");
@LoginUser
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
1. @Target(ElementType.PARAMETER)
이 어노테이션이 생성될 수 있는 위치 지정
PARAMETER로 지정했으니 메서드의 파라미터로 선언된 객체에서만 사용 할 수 있음
2. @interface
이 파일을 어노테이션 클래스로 지정
LoginUser라는 이름을 가진 어노테이션이 생성되었다고 보면 됨
LoginUserArgumentResolver : HandlerMethodArgumentResolver 구현체
→ 조건에 맞는 경우 메서드가 있다면 HandlerMethodArgumentResolver의 구현체가 지정한 값으로 해당 메서드의 파라미터로 넘길 수 있다.
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotation && isUserClass;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return httpSession.getAttribute("user");
}
}
1. supportsParameter()
컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
파라미터에 @LoginUser 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환
2. resolveArgument()
파라미터에 전달할 객체를 생성
여기서는 세션에서 객체를 가져온다.
WebConfig : LoginUserArgumentResolver가 스프링에서 인식도리 수 있도록 WebMvcConfigurer에 추가
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserArgumentResolver);
}
}
최종 패키지 구조

참고 : applicaiton.properties
spring.security.oauth2.client.registration.google.client-id=구글클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=구글클라이언트시크릿
spring.security.oauth2.client.registration.google.scope=profile,email
# registration
spring.security.oauth2.client.registration.naver.client-id=네이버클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=네이버클라이언트시크릿
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver
# 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
세션 저장소로 DB 사용하기
애플리케이션을 재실행하면 로그인 풀림 → 내장 톰캣의 메모리에 세션이 저장되기 때문!
내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화 되어버림! (세션 저장소 필요)
1. 톰캣 세션을 사용한다.
일반적으로 별다른 설정 하지 않은 경우 기본적으로 선택되는 방식
톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요
2. MySQl과 같은 DB를 세션 저장소로 사용
여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
많은 설정 필요 X, 결국 로그인 요청마다 DB IO가 발생하므로 성능 이슈 발생 가능성 존재
보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도로 사용
3. Redis, Memcahed와 같은 메모리 DB를 세션 저장소로 사용
B2C 서비스에서 가장 만이 사용
실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요
2. MySQl과 같은 DB를 세션 저장소로 사용
build.gradle
implementation 'org.springframework.session:spring-session-jdbc'
application.properties
spring.session.store-type=jdbc
책 내용 참고 : https://myeongju00.tistory.com/51
시큐리티 참고 : https://dunnnnno.medium.com/%EB%8B%B4%EB%B0%B1%ED%95%9C-spring-boot-oauth2-feat-google-674584950831
시큐리티 참고 : https://kim-jong-hyun.tistory.com/150