🌱 이번 장의 스터디 범위
- 스프링 시큐리티를 이용한 구글 로그인 연동 방법
🌱 구글 로그인 연동하기
- 사용자 정보를 담당한 도메인인 User 클래스 생성
// domain/user/User.java
@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 생성
// domain/user/Role.java
@Getter
@RequiredArgsConstructor
public enum Role {
/* 스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 함
그래서 코드별 키 값을 ROLE_GUSER, ROLE_USER 등으로 지정 */
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
- User의 CRUD를 책임질 UserRepository 생성
// domain/user/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
/* findByEmail : 소셜 로그인으로 반환되는 값 중 email을 통해
이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드 */
Optional<User> findByEmail(String email);
}
🌱 스프링 시큐리티 설정
- build.gradle에 스프링 시큐리티 관련 의존성 추가
// build.gradle
/* spring-boot-starter-oauth2-client : 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리 */
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
- config.auth 패키지 생성 : 시큐리티 관련 클래스는 모두 이곳에 담음
- OAuth 라이브러리를 이용한 소셜 로그인 설정 코드 SecurityConfig 클래스 생성
// config/auth/SecurityConfig.java
@RequiredArgsConstructor
/* @EnableWebSecurity
: Spring Security 설정들을 활성화시켜줌 */
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
/* csrf().disable().headers().frameOptions().disable()
: h2-console 화면을 사용하기 위해 해당 옵션들을 disable */
.csrf().disable()
.headers().frameOptions().disable()
.and()
/* authorizeRequests
: URL별 권한 관리를 설정하는 옵션의 시작점
-> 이것이 선언되어야 andMatchers 옵션 사용 가능
권한 관리 대상을 지정하는 옵션
URL, HTTP 메소드별로 관리가 가능
"/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 줌
"api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 함 */
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
/* anyRequest
: 설정된 값들 이외 나머지 URL을 나타냄
여기서는 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용
인증된 사용자 즉, 로그인 사용자들만 허용 */
.anyRequest().authenticated()
.and()
/* logout().logoutSuccessUrl("/")
: 로그아웃 기능에 대한 여러 설정의 진입점
로그아웃 성공 시 / 주소로 이동 */
.logout()
.logoutSuccessUrl("/")
.and()
/* oauth2Login
: OAuth2 로그인 기능에 대한 여러 설정의 진입점 */
.oauth2Login()
/* userInfoEndpoint
: OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당 */
.userInfoEndpoint()
/* userService
: 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
리소스 서버 (즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시 */
.userService(customOAuth2UserService);
}
}
- CustomOAuth2UserService 클래스 생성 : 구글 로그인 이후 가져온 사용자의 정보 (email, name, picture) 등을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원
- CustomOAuth2UserService에 구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현 : 사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영
// config/auth/CustomOAuth2UserService.java
@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);
/* registrationId
: 현재 로그인 진행 중인 서비스를 구분하는 코드
이 후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용 */
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
/* userNameAttributeName
: OAuth2 로그인 진행 시 키가 되는 필드값
Primary Key와 같은 의미
구글의 경우 기본적으로 코드를 지원 ("sub") 하지만, 네이버 카카오 등은 기본 지원하지 않음
이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용 */
.getUserInfoEndpoint().getUserNameAttributeName();
/* OAuthAttributes
: OAuth2UserService를 통해 가져온 OAuth2User의 attibutes를 담을 클래스
이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용 */
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
/* SessionUser
: 세션에 사용자 정보를 저장하기 위한 Dto 클래스*/
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);
}
}
- Dto를 위한 config.auth.dto 패키지 생성 후 OAuthAttributes 클래스 생성
// config/auth/dto/OAuthAttributes.java
@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;
}
// of : OAuth2User에서 반환되는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야 함
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();
}
/* toEntity()
: User 엔티티를 생성
OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입했을 때
가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용 */
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
- config.auth.dto 패키지에 SessionUser 클래스 생성 : 인증된 사용자 정보만 필요하며 그 외는 필요하지 않음
// config/auth/dto/SessionUser.java
@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 클래스를 사용하지 않고 Dto 세션을 추가로 만들어서 사용했을까?
- User 클래스를 세션에 저장하려고 할 시 User 클래스에 직렬화를 구현하지 않아 에러가 발생함
- 오류를 해결하기 위해 User 클래스에 직렬화 코드를 넣더라도 User 클래스는 엔티티이기 때문에 직렬화 대상에 자식 엔티티들까지 포함되면 성능 이슈, 부수 효과가 발생할 확률이 높아짐
- 그렇기 때문에 직렬화 기능을 가진 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 됨
🌱 로그인 테스트
- 스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼 추가
// resources/templates/layout/index.mustache
<div class="col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
/* {{#myName}}
: 머스테치는 다른 언어와 같은 if문을 제공하지 않고 true/false 여부만 판단
그래서 머스테치에서는 항상 최종값을 넘겨줘야 함
여기서는 myName이 있다면 myName을 노출시키도록 구성 */
{{#myName}}
Logged in as: <span id="user">{{myName}}</span>
/* a href="/logout"
: 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL
이로인해 개발자가 별도로 이 URL에 해당하는 컨트롤러를 만들 필요가 없음
SecurityConfig 클래스에서 URL을 변경할 수 있지만 여기선 기본 URL을 사용 */
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/myName}}
/* {{^myName}}
: 머스테치에서 해당 값이 존재하지 않는 경우에는 ^을 사용
userName이 없다면 로그인 버튼을 노출시키도록 구성 */
{{^myName}}
/* a href="/oauth2/authorization/google"
: 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL
로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없음 */
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/myName}}
</div>
- index.mustache에서 myName을 사용할 수 있게 IndexController에서 myName을 model에 저장하는 코드 추가
// web/dto/IndexController.java
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
/* (SessionUser) httpSession.getAttribute("user")
: CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성
즉, 로그인 성공 시 httpSession.getAttibute("user")에서 값을 가져올 수 있음 */
SessionUser user = (SessionUser) httpSession.getAttribute("user");
/* if(user != null)
: 세션에 저장된 값이 있을 때만 model에 userName으로 등록
세션에 저장된 값이 없으면 model엔 아무런 값이 없는 상태니 로그인 버튼이 보이게 됨 */
if(user != null) {
model.addAttribute("myName", user.getName());
}
return "index";
}
...
}
}
- 프로젝트를 실행해서 테스트 : Google Login 버튼이 잘 노출
- 버튼 클릭 시 구글 로그인 동의 화면으로 이동
- 본인의 계정을 선택해 로그인 과정을 진행
- 로그인 성공 시 구글 계정에 등록된 이름이 화면에 노출되는 것 확인
- 회원 가입도 잘 되어 있는지 http://localhost:8080/h2-console에 접속해 user 테이블 확인
- 데이터베이스에 정상적으로 회원정보가 들어간 것까지 확인 완료
- 권한 관리도 잘되는지 확인 : 현재 로그인된 사용자의 권한은 GUEST이므로 posts 기능을 사용할 수 없음
- 권한을 변경한 후 다시 실행 : h2-console로 가서 role을 USER로 변경
- 세션에는 이미 GUEST인 정보로 저장되어있으니 로그아웃한 후 다시 로그인하여 세션 정보를 최신 정보로 갱신한 후 글 등록
- 다음과 같이 정상적으로 글이 등록되는 것을 확인