이번 구글 로그인 연동하기는 보안, 로그인 관련 파트라서 어려움을 많이 느꼈다. 여러번 사용해보고 토이 프로젝트에도 소셜 로그인 기능을 녹여봐야겠다.
먼저 사용자 정보를 담당할 도메인인 User 클래스를 생성해주고 다음 코드를 작성해준다.
User
@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)
JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정함
기본적으로는 int로 된 숫자가 저장된다.
숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 가 없다.
그래서 문자열로 저장될 수 있도록 선언
*/
@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();
}
}
각 사용자의 권한을 관리할 Enum 클래스 Role을 생성해준다.
Role
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
/*
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야함
*/
User의 CRUD를 책임질 UserRepository도 생성해준다.
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
스프링 시큐리티를 설정해주기 위해 관련 의존성을 build.gradle에 추가해준다.
build.gradle
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.
config.auth 패키지를 생성하고 해당 패키지에 시큐리티 관련 클래스를 모두 담아준다.
SecurityConfig
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception{
http.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/image/**", "/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
처음에 이 코드를 작성하질 않아서 정상적으로 작동하지 않았다. 만약 처음 로컬 url로 접속 시 index 페이지가 아닌 로그인 페이지로 이동하고 로그인 버튼을 눌러도 반응하지 않는다면 이 코드를 작성했는지 확인하면 된다.
CustomOAuth2UserService 클래스를 생성해서 구글 로그인 이후 가져온 사용자 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원
CustomOAuth2UserService
@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 registratrionId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registratrionId, 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);
}
}
OAuthAtrribute
@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 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 userNameAtrributeName, 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(userNameAtrributeName)
.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();
}
}
테스트를 위해 index.mustache에 코드를 수정해준다.
index.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}}
<!--머스테치는 다른 언어와 달리 if문을 제공하지 않기에 최종값을 넘겨줘야함 -->
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>
{{/userName}}
</div>
</div>
<br>
{{#userName}} : 머스테치는 if 기능을 제공하지 않기에 userName이 있다면 userName을 노출시키도록 구성
a href="/logout" : 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 url
스프링 시큐리티에서 제공하기에 개발자가 해당 url에 해당하는 컨트롤러를 만들 필요가 없다.
{{^userName}} : 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.
a href="/oauth2/authorization/google" : 스프링 시큐리티에서 기본적으로 제공하는 로그인 url, 스프링 시큐리티에서 제공하기에 개발자가 별도의 컨트롤러를 만들 필요 없다.
index.mustache에서 userName을 사용할 수 있도록 IndexController에 userName을 model에 저장하는 코드를 추가한다.
IndexController
@GetMapping("/")
public String index(Model model){ //Model: 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
//여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if(user != null){
model.addAttribute("userName", user.getName());
}
return "index";
}
(SessionUser)httpSession.getAttribute("user"): 로그인 성공 시 세션에 SessionUser를 저장하도록 구성, 로그인 성공시 httpSession.getAtrribute("user")에서 값을 가져올 수 있도록 함
if(user != null) : 세션에 저장된 값이 있을 때만 model에 userName으로 등록