채채
5. 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 본문
스프링 시큐리티 : 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크
-> 인터셉터, 필터 기반의 보안 기능을 구현하기보다 확장성을 위해 스프링 시큐리티를 통해 구현하는 것을 권장.
CommonOAuth2Provider: 스프링부트 2.0방식으로 넘어오며 기본 설정값을 enum(열거형)으로 저장함
-> 구글, 페이스북, 옥타, 깃허브만 해당. 네이터, 카카오 등 다른 소셜 로그인을 추가한다면 직접 추가해야함.
1️⃣ 구글 로그인 연동하기
📜사용자 정보 담당 도메인 - User
User class - 사용자 정보 담당
// 생략
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
@Enumerated(EnumType.STRING)
: JPA로 데이터베이스에 저장할 때 Enum 값을 어떤 형태로 저장할지 결정
기본적으로 데이터베이스에는 int형으로 저장되는데, 숫자로 저장할 시 데이터베이스로 확인할 때 무슨 코드를 의미하는지 알 수 없으므로 문자열(EnumType.STRING)로 저장될 수 있도록 선언한다.
Role - 각 사용자의 권한 관리
// 생략
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야한다.
UserRepository - User의 CRUD
// 생략
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
findByEmail : 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단
📜스프링 시큐리티 설정
build.gradle
// 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현시 필요한 의존성
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
config.auth : 시큐리티 관련 클래스를 담는 패키지
SecurityConfig class
// import 생략
@RequiredArgsConstructor
@EnableWebSecurity // Spring Security 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// h2-console 화면 사용을 위해 옵션 disable
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
// 로그아웃 성공 시 / 주소로 이동
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
◾authorizeRequests
- URL별 권한 관리를 설정하는 옵션의 시작점. 선언 후 antMatchers 옵션 사용 가능
◾antMatchers
- 권한 관리 대상을 지정하는 옵션
- URL, HTTP 메소드 별로 관리 가능
ex )
"/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 줌.
"/api/v1/**"주소를 가진 API는 USER 권한을 가진 사람만
◾anyRequest
- 설정된 값들 이외의 나머지 URL들을 나타냄
- authenticated()로 인증된(로그인 한) 사용자들에게만 허용함
◾oauth2Login
- OAuth 2 로그인 기능에 대한 여러 설정의 진입점
◾ userInfoEndpoint
- OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
◾userService
- 소셜 로그인 성공 시 진행할 UserService 인터페이스의 구현체(여기서는 customOAuth2UserService)를 등록
CustomOAuth2UserService
- 로그인 후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능 지원
// import 생략
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 현재 로그인 진행 중인 서비스를 구분하기 위함. 구글인지 네이버인지
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드 값. PK와 같은 의미
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);
}
}
OAuthAttributes - OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 Dto 클래스.
// import 생략
@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;
}
// Map 형식의 사용자 정보를 변환
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 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();
}
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("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
// User 엔터티 생성
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
SessionUser - 세션에 인증된 사용자 정보를 저장하기 위한 Dto 클래스
// import 코드 생략
@Getter
// 직렬화(Serializable)를 구현
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();
}
}
❓왜 User class를 세션 저장에 사용하면 안 될까❓
user 클래스를 그대로 session 저장에 사용한다면 다음과 같은 에러가 발생한다.
Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.jojoldu.book.springboot.domain.user.User@4a43d6'
이는 User 클래스를 세션에 저장하려고 하니, User 클래스에 직렬화를 구현하지 않았다는 의미이다.
직렬화: 자바 시스템 내부에서 사용되는 객체(Object) 또는 Data를 외부 자바 시스템에서도 사용할 수 있도록 byte형태로 데이터를 변환하는 기술
그렇다면 일차원적인 해결 방법으로 'User 클래스에 직렬화 코드를 구현하면 되지 않을까?'
User 클래스는 엔터티로 정의되어있다. 즉 다른 객체와 다대다. 일대다의 관계를 맺을 수 있는데, User클래스를 직렬화 할 경우 자식 엔티티까지 직렬화 대상에 포함되어. 성능 이슈, 부수 효과가 발생할 확률이 높다.
따라서 직렬화 기능을 가진 세션 Dto를 만드는 것이 운영 및 유지보수에 용이하다.
2️⃣어노테이션 기반으로 개선하기
SessionUser user = (SessionUser) httpSession.getAttribute("user");
controller에서 해당 코드는 세션값이 필요할 때마다 반복되는 코드로 개선이 필요하다고 볼 수 있다.
그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 @LoginUser 어노테이션을 생성한다.
LoginUser - 세션값을 받을 수 있는 어노테이션 생성
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
// 파라미터로 선언된 객체에서 어노테이션이 생성될 수 있음.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
// LoginUser라는 이름을 가진 어노테이션 생성
public @interface LoginUser {
}
LoginUserArgumentResolver - HandlerMethodArgumentResolver 인터페이스 구현 클래스
// import 생략
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
@Override
// 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
// @LoginUser 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class 인 경우 true 반환
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");
}
}
🔹HandlerMethodArgumentResolver
- 조건에 맞는 경우 메소드가 있다면 지정한 값으로 해당 메소드의 파라미터를 인자값들에 주입한다.
주로 아래의 경우에 사용한다.
- parameter로 받는 값이 여러 개가 존재하고(혹은 객체의 필드들이 여러 개가 존재), 그것을 처리하는 코드들의 중복이 발생할 때
- Controller에 공통으로 입력되는 parameter들을 추가하거나 수정하는 등의 여러 공통적인 작업들을 한 번에 처리하고 싶을 때
https://webcoding-start.tistory.com/59
WebConfig - LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
// HandlerMethodArgumentResolver는 항상 아래 메소드를 통해 추가
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserArgumentResolver);
}
}
✔️IndexController 에서 최종적으로 반복되는 코드를 @LoginUser 로 개선하면 다음과 같다.
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
// 로그인 성공 시 세션 정보를 획득
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
// 세션에 저장된 값이 있을 때만 model에 userName으로 등록
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
3️⃣세션 저장소로 데이터베이스 사용하기
내장 톰캣 메모리에 저장되고 호출되는 구조는 애플리케이션을 재실행 할 때마다 항상 초기화가 되어 로그인이 유지되지 않는다는 것과. 2대 이상의 서버에서 서비스를 한다면 톰캣마다 세션 동기화 설정을 해야한다는 문제점이 있다.
따라서 MySQL과 같은 데이터베이스를 세션 저장소로 사용하는 방식을 택했다.
build.gradle - spring-session-jdbc 의존성 등록
compile('org.springframework.session:spring-session-jdbc')
application.properties - 세션 저장소를 jdbc로 선택
spring.session.store-type = jdbc
'spring boot' 카테고리의 다른 글
2. 스프링 부트 3 시작하기 (0) | 2023.07.12 |
---|---|
SpringBoot 패키지 개념 (0) | 2022.11.29 |
3-4. 게시물 등록/수정/조회/삭제 API 만들기 (0) | 2022.11.08 |
03. 스프링 부트에서 JPA로 데이터베이스 다루기 (0) | 2022.11.07 |
01.인텔리제이로 스프링부트 시작하기 (0) | 2022.11.07 |