생성 완료된 프로젝트를 선택하고, 왼쪽 메뉴 탭에서 API 및 서비스 - 사용자 인증 정보 - 사용자 인증 정보 만들기 버튼 클릭
이때, OAuth 클라이언트 ID를 선택하고 동의 화면 구성 클릭
OAuth 동의 화면에서 앱 이름, 사용자 지원 이메일, 개발자 연락처 정보 추가
범위에서 …/auth/userinfo.email, …/auth/userinfo.profile, openid 추가
모두 저장 후, 사용자 인증 정보 - 사용자 인증 정보 만들기 - OAuth 클라이언트 ID 클릭
애플리케이션 유형은 웹 애플리케이션, 이름은 프로젝트 이름
승인된 리디렉션 URI에 http://localhost:8080/login/oauth2/code/google 추가
클라이언트 ID와 비밀코드를 프로젝트에 등록
/src/main/resources에 application-oauth.properties 생성
application-oauth.properties
spring.security.oauth2.client.registration.google.client-id=클라이언트 IDspring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀spring.security.oauth2.client.registration.google.scope=profile, email
scope를 별도로 등록한 이유는, 이를 하지 않으면 기본값에 openid라는 scope가 있어 Open Id Provider로 인식하기 때문
이렇게 되면 OpenId Provider인 서비스(e.g. google)과 그렇지 않은 서비스(e.g. naver, kakao)로 나눠서 각각 OAuth2Service를 만들어야 함
application.properties에 아래 코드 추가
spring.profiles.include=oauth
ID와 비밀 노출을 방지하기 위해 .gitignore에 application-oauth.properties 등록
구글 로그인 연동하기
domain에 user 패키지를 생성하고, User 클래스와 Role 클래스, UserRepository 클래스 생성
User.java
package com.jojoldu.book.springboot.domain.user;import com.jojoldu.book.springboot.domain.BaseTimeEntity;import lombok.Builder;import lombok.Getter;import lombok.NoArgsConstructor;import javax.persistence.Column;import javax.persistence.Entity;import javax.persistence.EnumType;import javax.persistence.Enumerated;import javax.persistence.GeneratedValue;import javax.persistence.GenerationType;import javax.persistence.Id;@Getter@NoArgsConstructor@Entitypublic 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로 DB를 저장할 때 Enum값을 어떤 형태로 저장할지를 결정(기본은 int) @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(); }}
Role.java
package com.jojoldu.book.springboot.domain.user;import lombok.Getter;import lombok.RequiredArgsConstructor;@Getter@RequiredArgsConstructorpublic enum Role{ GUEST("ROLE_GUEST", "손님"), USER("ROLE_USER", "일반 사용자"); private final String key; private final String title;}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야 함.
UserRepository.java
package com.jojoldu.book.springboot.domain.user;import org.springframework.data.jpa.repository.JpaRepository;import java.util.Optional;public interface UserRepository extends JpaRepository<User, Long>{ // 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 판단 Optional<User> findByEmail(String email);}
springboot 내부에 config.auth 패키지를 만들고 SecurityConfig 클래스와 CustomOAuth2UserService 클래스 생성
SecurityConfig.java
package com.jojoldu.book.springboot.config.auth;import com.jojoldu.book.springboot.domain.user.Role;import lombok.RequiredArgsConstructor;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;@RequiredArgsConstructor@EnableWebSecurity // Spring Security 설정 활성화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 .and() .authorizeRequests() // URL별 권한 관리 설정 옵션의 시작점으로, antMatchers 옵션 사용 전에 선언돼야 함 .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll() // 전체 열람 권한 부여 .antMatchers("/api/v1/**").hasRole(Role.USER.name()) // antMatchers는 권한 관리 대상을 지정하는 옵션으로 URL, HTTP 메소드별로 관리 가능 (USER 권한만 열람 가능) .anyRequest().authenticated() // 설정된 값들 이외 나머지 URL (인증된 사용자, 즉 로그인한 사용자들에게 권한 부여) .and() .logout().logoutSuccessUrl("/") // 로그아웃 기능 설정의 진입점(로그아웃 성공시 /로 이동) .and() .oauth2Login() // OAuth2 로그인 기능에 대한 설정 진입점 .userInfoEndpoint() // 로그인 성공 후 사용자 정보를 가져올 때의 설정 담당 .userService(customOAuth2UserService); // 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스 구현체 등록 }}
CustomOAuth2UserService.java
package com.jojoldu.book.springboot.config.auth;import com.jojoldu.book.springboot.config.auth.dto.OAuthAttributes;import com.jojoldu.book.springboot.config.auth.dto.SessionUser;import com.jojoldu.book.springboot.domain.user.User;import com.jojoldu.book.springboot.domain.user.UserRepository;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 javax.servlet.http.HttpSession;import java.util.Collections;@RequiredArgsConstructor@Servicepublic 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(); // OAuth2 로그인 진행 시 키가 되는 필드값(Primary key) OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스 User user = saveOrUpdate(attributes); httpSession.setAttribute("user", new SessionUser(user)); // 세션에 사용자 정보를 저장하기 위한 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); }}
OAuthAttributes 클래스 생성
이 실습에서는 OAuthAttributes를 Dto로 보고 config.auth.dto 패키지를 생성함
package com.jojoldu.book.springboot.config.auth.dto;import com.jojoldu.book.springboot.domain.user.Role;import com.jojoldu.book.springboot.domain.user.User;import lombok.Builder;import lombok.Getter;import java.util.Map;@Getterpublic 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; } // 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(); } // User 엔티티 생성(OAuthAttribute에서 엔티티 생성 시점은 처음 가입할 때) // 기본 권한은 GUEST이고 클래스 생성이 끝나면 같은 패키지에 SessionUser 클래스 생성 public User toEntity() { return User.builder() .name(name) .email(email) .picture(picture) .role(Role.GUEST) .build(); }}
따라서, 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 운영 및 유지보수 때 도움이 됨
로그인 테스트
index.mustache를 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주도록 수정
<h1>스프링 부트로 시작하는 웹 서비스</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}} 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>
{{#userName}} : 머스테치는 if문을 제공하지 않고 true/false 여부만 판단하므로 항상 최종값을 넘겨줘야 함
a href=“/logout” : 스프링 시큐리티에서 제공하는 로그아웃 URL
{{^userName}} : 머스테치에 해당 값이 존재하지 않는 경우에는 ^ 사용
userName이 없다면 로그인 버튼 노출
a href=“/oauth2/authorization/google” : 스프링 시큐리티에서 제공하는 로그인 URL
IndexController에 userName을 model에 저장하는 코드를 추가해 index.mustache에서 userName을 사용할 수 있게 함.
...import javax.servlet.http.HttpSession;...@RequiredArgsConstructor@Controllerpublic class IndexController{ private final PostsService postsService; private final HttpSession httpSession; @GetMapping("/") public String index(Model model) { model.addAttribute("posts", postsService.findAllDesc()); SessionUser user = (SessionUser) httpSession.getAttribute("user"); // CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser 저장 if(user != null) // 세선에 저장된 값이 있을 때만 model에 userName으로 등록 { model.addAttribute("userName", user.getName()); } return "index"; } ...}
이후 구글 로그인을 시도하면 로그인이 되는 것을 확인할 수 있음
그러나 게시글 등록을 하면 403 에러가 나옴
로그인된 사용자의 권한이 GUEST인데, 글 등록은 USER 권한이 필요하기 때문
h2-console에 가서 role을 USER로 변환하면 글 등록이 가능함
어노테이션 기반으로 개선하기
같은 코드가 반복되는 경우 개선이 필요함
수정할 때, 반복되는 모든 부분을 하나씩 다 수정해야 하므로 유지보수성이 떨어진다
앞의 코드에서는 IndexController에서 세션값을 가져오는 부분을 개선할 수 있다
문제점 : index 메소드 외 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 세션에서 직접 값을 가져와야 함
개선방안 : 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경
config.auth 패키지에 @LoginUser 어노테이션 생성
package com.jojoldu.book.springboot.config.auth;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) // 이 파일을 어노테이션 클래스로 지정public @interface LoginUser { }
같은 위치에 LoginUserArgumentResolver를 생성
HandlerMethodArgumentResolver 인터페이스를 구현한 클래스
조건에 맞는 경우 메소드가 있다면 HandlerMethodArgumentResolver 구현체가 지정한 값을 해당 메소드의 파라미터로 넘길 수 있음
package com.jojoldu.book.springboot.config.auth;import com.jojoldu.book.springboot.config.auth.dto.SessionUser;import lombok.RequiredArgsConstructor;import org.springframework.core.MethodParameter;import org.springframework.stereotype.Component;import org.springframework.web.bind.support.WebDataBinderFactory;import org.springframework.web.context.request.NativeWebRequest;import org.springframework.web.method.support.HandlerMethodArgumentResolver;import org.springframework.web.method.support.ModelAndViewContainer;import javax.servlet.http.HttpSession;@RequiredArgsConstructor@Componentpublic class LoginUserArgumentResolver implements HandlerMethodArgumentResolver{ private final HttpSession httpSession; // 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단 // @LoginUser 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true @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"); }}
LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfig 클래스를 config 패키지에 생성
package com.jojoldu.book.springboot.config.auth;import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Configuration;import org.springframework.web.method.support.HandlerMethodArgumentResolver;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import java.util.List;@RequiredArgsConstructor@Configurationpublic class WebConfig implements WebMvcConfigurer{ private final LoginUserArgumentResolver loginUserArgumentResolver; @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { argumentResolvers.add(loginUserArgumentResolver); }}
HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 함
다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가
IndexController의 코드에서 반복되는 부분을 @LoginUser로 개선
... @GetMapping("/") public String index(Model model, @LoginUser SessionUser user) // 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세선 정보를 가져올 수 있음 { model.addAttribute("posts", postsService.findAllDesc()); if(user != null) { model.addAttribute("userName", user.getName()); } return "index"; }...
세션 저장소로 데이터베이스 사용하기
현재 서비스는 재실행을 하면 로그인이 풀림
세션이 내장 톰캣 메모리에 저장되기 때문
애플리케이션이 실행될 떄 실행되는 구조(e.g. 내장 톰캣)에선 항상 초기화
그리고 2대 이상의 서버에서 서비스하면 톰캣마다 세션 동기화 설정을 해야 함
세션 저장소 문제를 해결하기 위한 방법은 크게 3가지
톰캣 세션 사용
별도의 설정 필요 없어 기본적으로 선택
톰캣(WAS)에 세션이 저장되므로 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 설정이 필요
MySQL 같은 DB를 세션 저장소로 사용
WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
로그인 요청마다 DB IO가 발생해 성능상 이슈가 발생할 수 있음
Redis, Memcached 같은 메모리 DB를 세션 저장소로 사용
B2C 서비스에서 많이 사용
실제 서비스로 사용하려면 Embedded Redis 같은 방식이 아닌 외부 메모리 서버 필요
<!-- 구글 로그인 버튼 아래에 추가 --><a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
로그인 URL은 application-oauth.properties에 등록한 redirect-uri에 맞춰 자동으로 등록됨
/oauth2/authorization/는 고정. 마지막 Path만 각 소셜 로그인 코드
기존 테스트에 시큐리티 적용하기
기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결해야 함
기존에는 API를 바로 호출할 수 있어 테스트 코드 역시 API를 바로 호출하도록 구성
하지만 시큐리티 옵션이 활성화되면 인증된 사용자만 API 호출 가능
테스트 코드마다 인증한 사용자가 호출한 것처럼 수정해야 함
인텔리제이 오른쪽 상단에 Gradle-Tasks-verification-test를 선택해 전체 테스트 수행
롬복을 사용한 테스트 외에 스프링을 사용한 테스트는 모두 실패
CustomOAuth2UserService을 찾을 수 없음
returnHello() 메시지를 보면 No qualifying bean of type ‘com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService’라는 메시지 등장
src/main과 src/test의 환경이 다르기 때문
src/main/resources/application.properties 같은 경우, test에 application.properties가 없으면 main의 설정을 알아서 가져옴
application-oauth.properties는 자동으로 가져오지 않음
이를 해결하기 위해 src/test/resources에 application.properties 생성
spring.jpa.show-sql=truespring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialectspring.jpa.properties.hibernate.dialect.storage_engine=innodbspring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQLspring.h2.console.enabled=truespring.session.store-type=jdbc# Test OAuthspring.security.oauth2.client.registration.google.client-id=testspring.security.oauth2.client.registration.google.client-secret=testspring.security.oauth2.client.registration.google.scope=profile,email
302 Status Code
PostsRegister() 테스트 로그를 보면 Status Code가 200이 아닌 302가 와서 실패