WORK/STUDY

[Spring & OAuth2] 구글 로그인 연동

Justin Mendes 2023. 10. 27. 12:25
 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 예스24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

*위의 책을 따라 학습한 것을 정리한 내용입니다


Spring Security

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

-스프링 기반 애플리케이션 보안 표준

 

OAuth

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

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

 

OAuth 사용 이유

이 프로젝트에서도 그렇고 많은 서비스에서 OAuth를 사용하는데 로그인 기능을 직접 구현할 경우

배보다 배꼽이 커지는 경우가 많기 때문이다.

-로그인 시 보안, 인증, 회원정보 변경, 비밀번호 관련..

-위의 것들을 구글, 네이버에 맡기자

 

구글 신규 서비스 생성법은 아래를 통해 알아보자 !

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - 로그인 기능 구현하기

oauth2

velog.io

 

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의 설정 가져오기 가능

 

.gitignore에서 application-oauth.properties 를 추가한 뒤, 커밋하기 ! 

깃허브에 개인 정보를 공유할 순 없다. 

application.properties에 다음 코드 추가

spring.profiles.include=oauth

구글 로그인 연동하기

1. 도메인 생성

com//study/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();
	}
}

@Enumerated(EnumType.STRING)

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

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

 (숫자로 저장하면 무슨 값인 지 모르게 됨 > 문자로 저장하게 설정함)

 

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/study/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);
	}
}

 

@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/study/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);
	}
}

 

 

com/study/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/study/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 클래스는 엔티티 클래스이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다.

@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을 들어가서 로그인, 로그아웃이 되는지 테스트

 

게시글 등록 불가능한 현상

- 회원가입 시 초기 권한이 GUEST다. h2-console에 들어가서 role을 USER로 업데이트한 뒤 작성하면 된다.

 

 

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

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

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

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

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

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

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

 

com.study.springboot.config.auth 위치에 LoginUser 생성

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

@Target(ElementType.PARAMETER)

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

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

 

@interface

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

같은 위치에 LoginUserArgumentResolver 생성

 

같은 위치에 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");
	}
}

supportsParameter()

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

 

resolveArgument()

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

 

 

com.study.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";
}

 

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

 

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

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

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

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

 

spinrg-session-jdbc 등록

build.gradle에 다음 코드 추가

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

application.properties에 다음 코드 추가

spring.session.store-type=jdbc

 

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

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