본문 바로가기
Clone Coding/스프링 부트와 AWS

[OAuth 2] 구글 로그인 연동하기

by 연로그 2021. 1. 21.
반응형

Spring Security

- 막강한 인증, 인가 기능을 가진 프레임워크

 

OAuth

: 인터넷 사용자들이 비밀번호 제공하지 않고 다른 웹 사이트 상의 자신들의 정보에 대해 웹 사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단.

- ex: 네이버 아이디로 로그인, 구글 아이디로 로그인, ...

 

들어가기 전에...

본 프로젝트에서는 Spring Boot 2 방식Spring Security Oauth2 Client 라이브러리를 사용한다.

- 기존의 1.5에서 사용되던 프로젝트는 신규 기능이 더이상 없을 예정

- 스프링 부트용 라이브러리 출시

- 이전 사용 방식은 확장 포인트가 적절히 오픈되어 있지 않아 직접 상속/오버라이딩 해야함

 

이 책 외의 스프링 부트 2 방식의 자료를 찾고 싶은 경우,

- spring-security-oauth2-autoconfigure 라이브러리 사용
- application.properties 혹은 application.yml 정보의 차이
 (기존:url 주소 모두 명시 -> 2.0: client 인증 정보만 입력하면 됨)

위 두 가지만 확인하면 된다.

 

구글 로그인 인증정보 발급

1. console.cloud.google.com/ 접속 및 로그인

 

2. 새 프로젝트 생성

프로젝트 선택
새 프로젝트 생성

프로젝트 이름에 freelec-springboot2-webservice를 입력해 생성한다.

 

3.  사용자 인증 정보 만들기

 

API 및 서비스 클릭
사용자 인증 정보를 누르고 프로젝트를 선택

사용자 인증 정보 만들기 버튼을 클릭해 OAuth 클라이언트 ID를 생성한다.

 

4. 동의 화면 구성

User Type이나 앱 이름, 지원 이메일 등 정보를 입력한 뒤 생성한다.

필수가 아닌 것들은 굳이 입력할 필요 없다.

3으로 돌아가서 다시 클라이언트 ID를 누르면 다른 화면이 뜬다.

 

5. OAuth 클라이언트 ID 만들기

승인된 리디렉션 URI에는 http://localhost:8080/login/oauth2/code/google 를 입력하고 만들기 버튼을 누른다.

 

승인된 리디렉션 URI ▼

더보기

: 서비스에서 파라미터로 인증 정보를 주었을 때 인증 성공 시 구글에서 리다이렉트할 URL

- spring boot2 버전의 security에서는 기본적으로 {domain}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원한다.

- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller 생성할 필요 x

  (시큐리티에서 이미 구현해 놓음)

- AWS 서버에 배포 시 localhost 외 추가로 주소를 추가해야 함.

 

 

6. application-oauth.properties 생성

application.properties와 같은 위치(src/main/resources)에 생성

spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트PW
spring.security.oauth2.client.registration.google.scope=profile, email

id, pw는 위에서 받은 것으로 설정한다.

 

코드 설명 ▼

더보기

scope=profile, email

- 기본 값이 openid, profile, email이라서 별도 등록하지 않는 예제도 많다.

- 강제로 profile, email로 등록한 이유: openid라는 scope가 있을 경우 Open Id Provider로 인식

  (OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(카카오, 네이버, ...)로 나눠 OAuth2Service 만들어야 하는 상황이 오게 됨)

 

application-xxx.properties

- spring boot에서 위 형식의 파일을 생성할 경우, xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있음

- profile=xxx라는 식으로 호출하면 해당 properties의 설정 가져오기 가능

 

 

application.properties에 다음 코드 추가

spring.profiles.include=oauth

 

깃과 연동하는 사용자들의 경우, application-oauth.properties파일이 드러나는 것이 꺼려지는게 당연하다.

.gitignore에서 application-oauth.properties 를 추가한 뒤, 커밋했을 때 커밋 파일 목록에 해당 파일이 나타나지 않으면 성공.

 

구글 로그인 연동하기

1. 도메인 생성

com/spaws/book/springboot/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)
	@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 클래스 아직 생성되지 않은 상태라 붉은색 줄이 뜨는 것이 맞다)

 

코드 설명 ▼

더보기

@Enumerated(EnumType.STRING)

- JPA로 db 저장 시 Enum 값을 어떤 형태로 저장할지 결정

- 기본적으로는 int로 된 숫자가 저장

 (숫자로 저장 시 db 확인 할때 무슨 코드를 의미하는지 알 수 x -> 문자로 저장하게 설정함)

 

Role.java 생성

@Getter
@RequiredArgsConstructor
public enum Role {
	GUEST("ROLE_GUEST", "손님"),
	USER("ROLE_USER", "일반 사용자");
	
	private final String key;
	private final String title;
}

Spring Security에서는 권한 코드에 항상 ROLE_xxx 형식이어야 한다.

 

User의 CRUD 책임을 질 UserRepository 생성한다.

public interface UserRepository extends JpaRepository<User,Long> {
	Optional<User> findByEmail(String email);
}

 

2. Spring Security 설정

build.gradle 추가

compile('org.springframework.boot:spring-boot-starter-oauth2-client')

 

com/spaws/book/springboot/config/auth에 SecurityConfig.java 생성

앞으로 이 config.auth 패키지에 시큐리티 관련 클래스들을 모두 담을 예정이다.

@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/**","/images/**","/js/**","/h2-console/**","/profile").permitAll()
				.antMatchers("/api/v1/**").hasRole(Role.USER.name())
				.anyRequest().authenticated()
			.and()
				.logout().logoutSuccessUrl("/")
			.and()
				.oauth2Login()
					.userInfoEndpoint()
						.userService(customOAuth2UserService);
	}
}

(CustomOAuth2UserService은 아직 만들지 않은 클래스라 빨간줄이 뜬다.)

코드 설명 ▼

더보기

@EnableWebSecurity

- Spring Security 설정들 활성화 

 

.csrf().disable().headers().frameOptions().disable()

- h2 콘솔을 위한 설정들 disable

 

authorizeRequests

- URL별 권한 권리를 설정하는 옵션의 시작점

- authorizeRequests가 선언되어야만 antMatchers 옵션 사용 가능

 

antMatchers

- 권한 관리 대상 지정

- URL, HTTP 메소드별 관리 가능

- "/"등 지정된 URL은 permitAll() 옵션을 통해 전체 열람 권한

- "/api/v1/**" 주소를 가진 api는 USER 권한을 가진 사람만 가능

 

anyRequest

- 설정된 값들 이외 나머지 URL들

- 여기서는 .authenticated()를 추가해 나머지 url들은 모두 인증된 사용자들에게만 허용하게 함

  (인증된 사용자=로그인한 사용자)

 

logout().logoutSuccessUrl("/")

- 로그아웃 성공 시 / 주소로 이동

 

oauth2Login

- OAuth 2 로그인 기능에 대한 설정 진입

 

userInfoEndpoint

- OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들 담당

 

userService

- 로그인 성공 시 후속 조치를 진행할 UserService.interface의 구현체 등록

- 리소스 서버(소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시 가능

 

com/spaws/book/springboot/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 delegate = new DefaultOAuth2UserService();
		OAuth2User oAuth2User = delegate.loadUser(userRequest);
		
		/* registrationId
		 * 현재 로그인 진행 중인 서비스 구분하는 코드. 
		 * 이후에 여러가지 추가할 때 네이버인지 구글인지 구분
		 */
		String registrationId = userRequest.getClientRegistration().getRegistrationId();
		
		/* userNameAttributeName
		 * OAuth2 로그인 진행 시 키가 되는 필드값 (=Primary Key) 
		 * 구글 기본 코드: sub, 네이버 카카오 등은 기본 지원 x
		 * 이후 네이버, 구글 로그인 동시 지원시 사용
		 */
		String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
					.getUserInfoEndpoint().getUserNameAttributeName();
		
		/* OAuthAttributes
		 * OAuth2UserService를 통해 가져온 OAuth2User의 attribute
		 * 네이버 등 다른 소셜 로그인도 이 클래스 사용
		 */
		OAuthAttributes attributes = OAuthAttributes.
				of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
		
		User user = saveOrUpdate(attributes);
		
		/* SessionUser
		 * 세션에 사용자 정보를 저장하기 위한 dto 클래스
  		 * (User 클래스를 사용하지 않고 새로 만들었다.)
		 */
		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);
	}
}

(OAuthAttOAuthAttributes와 SessionUser는 아직 생성하지 않아 import 되지 않는게 정상이다.)

 

com/spaws/book/springboot/config/auth/dto에 OAuthAttOAuthAttributes.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 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에서 엔티티 생성 시점 = 처음 가입 시
	 * OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스 생성
	 */
	public User toEntity() {
		return User.builder()
			.name(name)
			.email(email)
			.picture(picture)
			.role(Role.GUEST)	// 가입 기본 권한 == GUEST
			.build();
	}
}

com/spaws/book/springboot/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();
	}
}

SessionUser은 인증된 사용자 정보만 필요하다.

그 외 정보들은 필요가 없어  name, email, picture만 필드로 선언한다.

 

User 클래스를 두고 SessionUser 클래스를 따로 생성한 이유가 뭘까?

User 클래스를 사용한다면 Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.spaws.book.springboot.domain.user.User@4a43d6' 이런 에러가 뜰 것이다.

이를 해석하자면 User 클래스를 세션에 저장하려 했지만 User 클래스에 직렬화를 구현하지 않았다는 의미다.

 

User 클래스는 엔티티 클래스이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다.

@OneToMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 그 자식까지 직렬화 대상에 포함된다.

성능 이슈, 부수 효과가 발생할 확률이 높아지므로 직렬화 기능을 가진 세션 Dto를 따로 생성하는 것이 운영 및 유지보수에 많은 도움이 된다.

 

3. 로그인 테스트

index.mustache에 로그인 버튼 및 로그인 성공 시 사용자 이름을 보여주는 코드를 추가했다.

...
<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 acitve"
				role="button">Google Login</a>
			{{/userName}}
		</div>
	</div>
	<br>
	<!-- 목록 출력 영역 -->
    ...
    

코드 설명 ▼

더보기

{{#userName}}

- Mustache는 if문을 제공하지 않고 T/F 여부만 판단한다.

  -> 항상 최종 값을 넘겨줘야 한다.

- userName이 있다면 userName을 노출

 

 a href="/logout"

- spring security 에서 기본적으로 제공하는 로그아웃 URL 

 (별도로 이 URL에 해당하는 컨트롤러 만들 필요 X)

- SecurityConfig 클래스에서 URL 변경 가능\

 

{{^userName}}

- Mustache에 해당 값이 존재하지 않는 경우 ^ 사용

- userName이 없다면 로그인 버튼 노출

 

a href="/oauth2/authorization/google"

- spring security에서 기본적으로 제공하는 로그인 URL

 (별도로 이 URL에 해당하는 컨트롤러 만들 필요 X)

IndexController에 코드 수정

...
	private final PostsService postsService;
	private final HttpSession httpSession;
	
	@GetMapping("/")
	public String index(Model model) {
		model.addAttribute("posts", postsService.findAllDesc());
		
		// 로그인 성공 시 httpSession.getAttribute("user") 에서 값 가져올 수 있음
		SessionUser user = (SessionUser) httpSession.getAttribute("user");
		
		if(user!=null) {	// session에 저장된 값이 있을 때만 model에 userName으로 등록
			model.addAttribute("userName", user.getName());
		}
		
		return "index";
	}
...

 

 

이제 localhost:8080을 들어가서 로그인, 로그아웃이 되는지 테스트해보면 된다.

 

+ 테스트 오류

오류1 WhiteLabel

-> 파일을 찾지 못했을 수 있다.

-> CustomOAuth2UserService에서 delegate 변수의 타입이 OAuth2UserService<OAuth2UserRequest, OAuth2User>가 아닌 OAuth2UserService 이어야 한다.

 

오류2 localhost:8080 접속 시 구글 화면으로 감

- 패키지 위치 확인

- config 파일 확인 (config파일에 일부 빠뜨린 코드가 있었어서 오류가 났다.)

 

오류3 이름이 계정 이름이 아닌 OS 사용자 이름으로 뜨는 경우

- IndexController의 addmodel.addAttribute("userName", user.getName()); 부분에서 "userName"을 "userNames"로 바꿨다. (index.mustache도 따라서 userName -> userNames로)

userName이라는 이름을 스프링 부트나.. 다른 곳에서 사용하고 있기 때문에 충돌난 것이 아닌가 싶다. db에는 변경 없이도 문제 없이 저장된다.

 

오류4 게시글 등록 불가능한 현상

- 회원가입 시 권한이 GUEST라서 그렇다. db에 들어가서 role을 USER로 수정한 뒤 작성하면 된다.

 

 

어노테이션 기반으로 개선하기

앞서 만든 코드에선 개선점이 하나 있다. 

IndexController에서 세션 값을 가져오는 부분을 보자

SessionUser user = (SessionUser) httpSession.getAttribute("user");

index 메소드 외에 다른 컨트롤러와 메소드에서 세션 값이 필요하다고 하자.

그러면 그때마다 직접 세션에서 값을 가져고와 같은 코드가 계속해서 반복되는 불필요한 현상이 일어난다.

그래서 이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보겠다.

 

com.spaws.book.springboot.config.auth 위치에 LoginUser 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

코드 설명 ▼

더보기

@Target(ElementType.PARAMETER)

- 이 어노테이션이 생성될 수 있는 위치 지정

- PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능

 

@interface

- 어노테이션 클래스로 지정

 

같은 위치에 LoginUserArgumentResolver 생성

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
	private final HttpSession httpSession;
	
	/* supportsParameter()
	 * isLoginUserAnnotation: 파라미터에 @LoginUser 붙어 있는지
	 * isUserClass		: 파라미터 클래스 타입이 SessionUser.class인지
	 */
	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
		boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
		
		return isLoginUserAnnotation && isUserClass;
	}
	/* resolveArgument()
	 * 세션에서 객체를 가져오기
	 */
	@Override
	public Object resolveArgument (MethodParameter parameter, ModelAndViewContainer mavContatiner, 
				NativeWebRequest ebRequest, WebDataBinderFactory binderFactory) throws Exception {
		return httpSession.getAttribute("user");
	}
}

코드 설명 ▼

더보기

Override한 두 메소드를 살펴보자.

supportsParameter()

- 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단

 

resolveArgument()

- 파라미터에 전달할 객체 생성

 

com.spaws.book.springboot.config에 WebConfig 파일 생성

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
	private final LoginUserArgumentResolver loginUserArgumentResolver;
	
	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
		argumentResolvers.add(loginUserArgumentResolver);
	}
}

 

이제 IndexController을 @LoginUser에 맞춰 수정하자.

@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
	model.addAttribute("posts", postsService.findAllDesc());
	if(user!=null) {	// session에 저장된 값이 있을 때만 model에 userName으로 등록
		model.addAttribute("userNames", user.getName());
	}
	return "index";
}

 

다시 http://localhost:8080/ 에 들어가면 수정 완료된 것을 확인할 수 있다.

또다시 WhiteLabel 에러가 뜬다면 파일 위치나 오타 등을 잘 살펴보자.

 

세션 저장소로 데이터베이스 사용하기

현재 상태로는 세션이 내장 톰캣의 메모리에 저장되기 때문에 애플리케이션을 재실행하면 로그인이 풀린다.

(내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화 = 배포할 때마다 톰캣 재시작)

 

그래서 실제 현업에서는 다음 세 가지 중 한 가지를 선택한다.

 

1) 톰캣 세션 사용

   - 디폴트

   - WAS가 2대 이상일 경우 톰캣 간의 세션 공유를 위한 추가 설정 필요

 

2) MySQL과 같은 데이터베이스를 세션 저장소로 사용

   - 여러 WAS간 공용 세션 사용할 수 있는 가장 쉬운 방법

   - 많은 설정 필요 x. 로그인 요청마다 DB IO가 발생 -> 성능 이슈

   - 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용

 

3) Redis, Memecahced와 같은 메모리 db를 세션 저장소로 사용

   - B2C 서비스에서 많이 사용

   - 실제 서비스 사용을 위해 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요

 

본 프로젝트에서는 2번 방법으로 진행하겠다.

 

spinrg-session-jdbc 등록

build.gradle에 다음 코드 추가

compile('org.springframework.session:spring-session-jdbc')

application.properties에 다음 코드 추가

spring.session.store-type=jdbc

 

아직은 코드를 추가함에도 불구하고 스프링을 재시작하면 세션이 풀린다.

h2 기반으로 스프링이 재실행될 때 h2도 재시작되기 때문인데 나중에 AWS로 배포하게 된다면 RDS를 사용할 예정이니 그때부터는 세션이 풀리지 않을 것이다.


해당 게시글은 [ 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 / 이동욱 ] 책을 따라한 것을 정리하기 위한 게시글입니다. 요약, 생략한 부분이 많으니 보다 자세한 설명은 책 구매를 권장합니다.

 

오류 참고github.com/jojoldu/freelec-springboot2-webservice/issues/549

반응형