SpringSecurity를 이용하여 Google OAuth2 Login을 구현해보도록 한다.
config 패키지에는 SessionUser와 SecurityConfig 클래스를 작성해주고,
Index (/) 경로로 들어오는 Client만 허용하도록 BaseController에 해당 분기를 설정한다.
또한 domain은 Role(일반 사용자, 손님) Entity와 User Entity를 작성하고,
Service로는 CustomOAuth2UserService를 작성하여 비즈니스 로직을 구현해준다.
의존성
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
SpringSecurity 설정
SpringSecurity는 FilterChainProxy라는 이름으로 내부에 여러 Filter들이 동작하고 있다.
별도의 Logic을 작성하지 않고 설정만으로 로그인/로그아웃 등의 처리가 가능하다.
또한 OAuth2 인증 방식을 Google, Kakao, Naver, Facebook, Github 등을 통해서 타 사이트의 계정으로도 Login이 가능하다.
본 글에서는 OAuth2 Google 로그인을 구현해 볼 예정이다.
다음의 순서대로 설명을 진행해 볼 예정이다.
0. Google 서비스 등록
1. application-oauth2.yml 작성
2. SecurityConfig : 스프링 시큐리티 설정
3. Role - Enum 클래스
4. User 클래스
5. OAuthAttributes 클래스 : DTO
6. CustomOAuth2UserService : OAuthAttributes를 기반으로 해당 웹 사이트 및 정보 수정, 세션 저장 등
7. SessionUser 클래스 : User Entity 클래스에서 직렬화가 필요한 경우 별도 사용
8. BaseController
SpringSecurity로 OAuth2 Google 인증
- OAuth2 Authorization : Client가 서비스 제공자(카카오, 구글, ..)로부터 회원 리소스를 제공받기 위해 인증 및 권한 부여를 받는 일련의 절차
0. 구글 서비스 등록
client-id와 client-secret을 GCP에서 발급 받아 저장해둔다.
1. application-oauth2.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: xxxxxx-xxxxx.apps.googleusercontent.com
client-secret: XXXXXX-xxxx-XXXXXXXX-XXXXXXX
scope: email,profile
++ .gitignore 설정
크레덴셜 정보가 있기 때문에 gitignore에 추가해줘야 함
2. SecurityConfig 파일 작성
package shop.pingping2.board.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import shop.pingping2.board.domain.Role;
import shop.pingping2.board.service.CustomOAuth2UserService;
@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("/", "/Savory-gh-pages/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/")
.and()
.logout().logoutSuccessUrl("/")
.and()
.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
}
}
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@EnableWebSecurity
- Spring Security 설정들을 활성화하기 위한 애노테이션이다.
.csrf().disable().headers().frameOptions().disable()
- h2-console 화면을 사용하기 위해 해당 옵션들을 disable
authorizeRequests
- URL별 권한 관리를 설정하는 옵션의 시작점
- authorizeRequests가 선언되어야만 antMatchers 옵션을 사용이 가능하다.
antMatchers
- 권한 관리 대상을 지정하는 옵션이다.
- URL, HTTP 메서드별로 관리가 가능하다.
.anyRequest().authenticated()
- 설정된 값을 이외 나머지 URL들을 나타낸다.
- authenticated() 메서드를 통해 나머지 URL들은 모두 인증된 사용자에게만 허용하게 한다.
.oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
- OAuth2 로그인 기능에 대한 여러 설정의 진입점
- userInfoEndpoint : OAuth2 로그인이 성공된 이후 사용자 정보를 가져올 때 설정을 담당한다.
- userService
: 소셜 로그인 성공 시 후속 조치를 진행 할 UserService 인터페이스의 구현체를 등록한다.
: 서비스 제공자(Google, Kakao, ..)에서 사용자 정보를 가져온 정보 (Email, Name, Picture, ..)을 기반으로 추가로 진행하고자 하는 기닝들을 명시할 수 있다.
(예를 들어, 해당 정보를 가지고 우리 웹 서비스의 DB에 사용자들을 저장한다든지 등등..)
3. Role - Enum 클래스
사용자의 권한을 Enum 클래스로 만들어 관리한다.
package shop.pingping2.board.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
4. User 클래스 작성
package shop.pingping2.board.domain;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@Entity
@Table(name = "user")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서의 생성을 열어 둘 필요가 없을 때 / 보안적으로 권장된다.
public class User extends Time{
@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();
}
}
5. OAuthAttributes 클래스 : DTO
: 구글 로그인 이후 가져온 사용자의 이메일, 이름, 프로필 사진 주소를 저장하는 DTO
package shop.pingping2.board.dto;
import lombok.Builder;
import lombok.Getter;
import shop.pingping2.board.domain.Role;
import shop.pingping2.board.domain.User;
import java.util.Map;
@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);
}
public 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();
}
}
6. CustomOAuth2UserService : OAuthAttributes를 기반으로 해당 웹 사이트 및 정보 수정, 세션 저장 등
OAuthAttributes을 기반으로 가입 및 정보수정, 세션 저장 등 기능 수행
package shop.pingping2.board.service;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import shop.pingping2.board.config.auth.SessionUser;
import shop.pingping2.board.domain.User;
import shop.pingping2.board.dto.OAuthAttributes;
import shop.pingping2.board.repository.UserRepository;
import javax.servlet.http.HttpSession;
import javax.transaction.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Transactional
@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);
// OAuth2 서비스 id (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user)); // SessionUser (직렬화된 dto 클래스 사용)
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);
}
}
// OAuth2 서비스 id (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
현재 로그인 진행 중인 서비스를 구분하는 코드이다.
본 글은 Google로만 Login을 진행하기 때문에 필요가 없을 수 있지만 추후 Naver, Kakao 등 소셜 로그인이 추가될 때 어떤 소셜 제공자인지 구분하기 위한 부분이다.
// OAuth2 로그인 진행 시 키가 되는 필드 값(PK)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
- OAuth2 로그인 진행 시 키가 되는 필드값. Primary Key와 같은 의미
// OAuth2UserService
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user)); // SessionUser (직렬화된 dto 클래스 사용)
- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 attributes란 DTO로 담는다.
- 이후, saveOrUpdate 메서드의 인자로 attributes를 넣어준다.
- 이후, 세션에 사용자 정보를 저장하기 위해 SessionUser 클래스에 인자로 user를 넣어서 httpSession의 Attribute를 추가해준다.
=> 세션에 저장하기 위해 기 생성한 User Class를 세션에 저장하려고 하면 User 클래스에 직렬화를 구현하지 않았다는 에러가 발생하여 따로 SessionUser를 구현
// 유저 생성 및 수정 서비스 로직
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);
}
말 그대로 유저를 생성하거나 수정하는 서비스 로직이다.
7. SessionUser 클래스 : User Entity 클래스에서 직렬화가 필요한 경우 별도 사용
package shop.pingping2.board.config.auth;
import shop.pingping2.board.domain.User;
import lombok.Getter;
import java.io.Serializable;
@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();
}
}
User 클래스가 이미 있는데 왜 따로 SessionUser 클래스를 생성하였을까?
- Entity 클래스는 직렬화 코드를 넣지 않는게 좋다
- Entity 클래스에는 언제 다른 Entity와 Relationship이 형성될지 모른다.
- @OneToMany, @ManyToMany등 자식 Entity를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다
=> 그래서 직렬화 기능을 가진 세션 DTO를 하나 추가로 만든 것이 더 좋은 방법이다.
8. BaseController
package shop.pingping2.board.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import shop.pingping2.board.config.auth.SessionUser;
import javax.servlet.http.HttpSession;
@RequiredArgsConstructor
@Controller
public class BaseController {
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model){
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if(user != null) {
model.addAttribute("userName", user.getName());
model.addAttribute("userImg", user.getPicture());
}
return "index";
}
}
(SessionUser) httpSession.getAttribute("user")
=> 앞서 작선된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성
=> 즉, 로그인 성공시 httpSerssion.getAttribute("user")에서 값을 가져올 수 있다.
user가 null 값일 경우 index 페이지에 model 값에 attribute를 전달하기 못할 것이다.
로그인 테스트
1. Index 페이지는 접근 가능
2. Index 페이지 외에 다른 페이지 접근
: 로그인 페이지로 리디렉션
3. Google로 OAuth2 로그인 수행
4. 로그인 후 게시판에 정상 접근이 가능해짐
지금까지 Spring Security로 OAuth2 google 로그인을 겉핧아보았다.
다음 시간에는 Front View 부분까지 완성 후 최종 확인해 볼 예정이다.
Ref
Jojoldu님 책
http://www.yes24.com/Product/Goods/83849117
http://yoonbumtae.com/?p=2652
https://daddyprogrammer.org/post/1239/spring-oauth-authorizationserver/
'Programming > SpringBoot' 카테고리의 다른 글
[SpringBatch] 스프링배치 개념을 알아보자. (0) | 2023.06.25 |
---|---|
[SpringBoot] 간단한 게시판 만들기 #7 - Thymeleaf를 사용한 Fornt view 구현 (5) | 2022.01.03 |
[SpringBoot] 간단한 게시판 만들기 #5 - Service 구현 (0) | 2022.01.03 |
[SpringBoot] 간단한 게시판 만들기 #4 - DTO, Repository 구현 (0) | 2022.01.03 |
[SpringBoot] 간단한 게시판 만들기 #3 - domain(Entity) 구현 (0) | 2022.01.03 |