더 많은 지식을 얻기 위해 이번엔 스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 지음) 책을 읽고, 공부하고 정리했습니다.
스프링 부트 2.0에서의 OAuth 연동 방법이 크게 변경되었다. 하지만, 인터넷 자료들을 보면 설정 방법에 크게 차이가 없는 경우를 자주 봅니다.
spring-security-oauth2-autoconfigure
라이브러리를 사용할 경우 쓰던 설정을 그대로 사용할 수 있다. 기존에 안전하게 작동하던 코드를 사용할 수 있다.
@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로 된 숫자가 저장되지만 데이터베이스로 확인할 때 그 값의 의미를 파악하기 힘들어 문자열로 저장될 수 있게 한다.
각 사용자 권한을 관리할 Enum 클래스 Role을 생성한다.
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 합니다. 그래서 코드별 키 값을 ROLE_GUEST, ROLE_USER 등으로 지정한다.
마지막으로 User의 CRUD를 책임질 UserRepository도 생성한다.
findByEmail
: 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드이다.
먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나를 추가해 준다.
spring-boot-starter-oauth2-client
: 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성이다.
@EnableWebSecurity
: Spring Security 설정들을 활성화시켜 준다.
csrf().disable().headers().frameOptions().disable()
: h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
authorizeRequests
: URL별 권한 관리를 설정하는 옵션의 시작점이다.
antMatchers
: 권한 관리 대상을 지정하는 옵션, URL, HTTP 메소드별로 관리가 가능하다.
anyRequest
: 설정된 값들 이외 나머지 URL들을 나타낸다.
logout().logoutSuccessUrl("/")
: 로그아웃 기능에 대한 여러 설정의 진입점, 로그아웃 성공 시 / 주소로 이동한다.
oauth2Login
: OAuth 2 로그인 기능에 대한 여러 설정의 진입점이다.
userInfoEndpoint
: OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
userService
: 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다.
@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<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.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);
}
}
registrationId
: 현재 로그인 진행 중인 서비스를 구분하는 코드이다. (ex. 구글 로그인인지, 네이버 로그인인지)
userNameAttributeName
: OAuth2 로그인 진행 시 키가 되는 필드값을 이야기이다. (Primary Key와 같은 역할), 구글의 경우 기본 코드는 "sub", 네이버나 카카오는 따로 지원하지 않는다. 네이버 로그인과 구글 로그인을 동시 지원할 때 사용한다.
OAuthAttributes
: OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.
SessionUser
: 세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.
@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) {
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();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
of()
: OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 한다.
toEntity()
: User 엔티티를 생성, OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때이다. (가입할 때 기본 권한을 GUEST로 주기 위해 role 빌더값엔 Role.GUEST를 사용)
index.mustache에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드이다.
{{#userName}}
: userName이 있으면 다음 줄 부터 {{/userName}}까지 화면에 보여준다.
{{^userName}}
: userName이 없으면 다음 줄 부터 {{/userName}}까지 화면에 보여준다.
a href="/logout"
: 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL, 별도의 Controller를 생성할 필요가 없다.
a href="/oauth2/authorization/google"
: 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL, 별도의 Controller를 생성할 필요가 없다.
index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 model에 저장하는 코드를 추가해 준다.
(SessionUser) httpSession.getAttribute("user")
: CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했다. 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다.
if (user != null)
: 세션에 저장된 값이 있을 때만 model에 userName으로 등록한다.
SessionUser user = (SessionUser) httpSession.getAttribute("user");
세션값 가져오는 부분은 다른 컨트롤러와 메소드에서 필요로 할 수 있다.
config.auth 패키지에 @LoginUser 어노테이션을 생성한다.
@Target(ElementType.PARAMETER)
: 해당 어노테이션이 생성될 수 있는 위치를 지정, PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있다.
@interface
: 해당 파일을 어노테이션 클래스로 지정한다.
같은 위치에 LoginUserArgumentResolver를 생성한다. Login-UserArgumentResolver라는 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");
}
}
HandlerMethodArgumentResolver
: 컨트롤러에서 구현체가 지정한 값으로 파라미터를 바인딩해주는 역할, view페이지에서 전달해주는 파라미터가 아닌 파라미터들을 바인딩해줄 때 사용한다.
supportsParameter()
: 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단한다.
resolveArgument()
: 파라미터에 전달할 객체를 생성, 여기선 세션에서 user를 가져온다.
LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가한다.
addArgumentResolvers()에 구현한 HandlerMethodArgumentResolver를 등록한다.
@LoginUser SessionUser user
: 기존에 (User) httpSession.getAttribute("user") 로 가져오던 세션 정보 값이 개선, 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있다.