스프링 시큐리티는 막강한 인증(Authentication)과 인가(Authorization)기능을 가진 프레임워크로 사실상 스프링 기반 애플리케이션에서 보안을 위한 표준이다
이번 장에서는 스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어본다
많은 서비스에서 로그인 기능을 id, pw 방식보단 구글, 페이스북, 네이버 로그인과 같은 소셜 로그인 기능을 사용한다
직접 구현할 경우 로그인 시 보안, 이메일/전화번호 인증, 비밀번호 찾기/변경 등 구현해야 할 기능이 많아 배보다 배꼽이 더 커지기 때문이다
스프링 부트 2.0에서 OAuth2 연동 방법이 크게 바뀌었는데, 설정 방법에 크게 차이가 없는 경우를 자주 볼 수 있다
이는 spring-security-oauth2-autoconfigure
라이브러리 덕분인데, 1.5에서 쓰던 설정을 2에서도 그대로 사용할 수 있게 매칭해준다
하지만 이 책에서는 스프링부트 2 방식인 Spring Security Oauth2 Client를 사용한다
CommonOAuth2Provider
라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북의 기본 설정값은 모두 여기서 제공한다구글 클라우드 플랫폼 에서 새로운 프로젝트를 등록한다
API및 서비스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID
먼저, 다음과 같은 범위의 동의 화면을 구성한다
OAuth 클라이언트 ID를 생성한다
승인된 리디렉션 URI
{도메인}/login/oauth2/code/{소셜서비스코드}
로 리다이렉트 URL을 지원하고 있다성공적으로 생성되었다면, 클라이언트 ID와 클라이언트 보안 비밀을 프로젝트에 설정한다
src/main/resources 디렉토리에 application-oauth.properties
파일을 생성하고 클라이언트 ID와 보안 비밀을 입력한다
spring.security.oauth2.client.registration.google.client-id=...
spring.security.oauth2.client.registration.google.client-secret=...
spring.security.oauth2.client.registration.google.scope=profile,email
scope=profile,email
스프링 부트에서는 properties의 이름을 application-xxx.properties
로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다
즉, profile=xxx
와 같이 호출하면 해당 properties의 설정들을 가져올 수 있다
여기선 스프링 부트의 기본 설정 파일인 application.properties
가 application-oauth.properties
를 포함하도록 구성한다
spring.profiles.include=oauth
구글 로그인을 위한 클라이언트 ID와 보안 비밀은 보안적으로 중요한 정보들이므로 gitignore에 추가해준다
# Project exclude paths
.gradle
.idea
application-oauth.properties
package com.vencott.dev.springboot.domain.user;
import com.vencott.dev.springboot.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@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();
}
}
먼저, 사용자 정보를 담당할 도메인인 User 클래스를 생성한다
@Enumerated(EnumType.STRING)
package com.vencott.dev.springboot.domain.user;
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;
}
다음은 각 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다
스프링 시큐리티에서는 권한 코드에 항상 ROLE_
이 앞에 있어야 한다
package com.vencott.dev.springboot.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
findByEmail()
먼저 build.gradle에 스프링 시큐리티 관련 의존성을 추가한다
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
시큐리티 관련 클래스를 담는 config.auth 패키지를 생성하고 SecurityConfig 클래스를 생성한다
package com.vencott.dev.springboot.config.auth;
import com.vencott.dev.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
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/**").permitAll()
.antMatchers("/api/v1/**").hasRole((Role.USER.name()))
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
@EnableWebSecurity
csrf().disable().headers().frameOptions().disable()
authorizeRequests()
antMatchers()
anyRequest().authenticated()
logout().logoutSuccessUrl("/")
oauth2Login()
userInfoEndPoint()
userService()
package com.vencott.dev.springboot.config.auth;
import com.vencott.dev.springboot.domain.user.User;
import com.vencott.dev.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
@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);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
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);
}
}
registrationId
userNameAttributeName
OAuthAttributes
SessionUser
saveOrUpdate()
OAuthAttributes는 Dto로 취급하기 때문에 config.aut.dto
패키지에 생성
package com.vencott.dev.springboot.config.auth.dto;
import com.vencott.dev.springboot.domain.user.Role;
import com.vencott.dev.springboot.domain.user.User;
import lombok.Builder;
import lombok.Getter;
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();
}
}
of()
toEntity()
package com.vencott.dev.springboot.config.auth.dto;
import com.vencott.dev.springboot.domain.user.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();
}
}
SessionUser에는 인증된 사용자 정보만 필요하므로 3가지 필드만 선언한다
User 클래스 대신 SessionUser를 새로 생성한 이유
index.mustache
에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드를 추가한다
<!--로그인 기능 영역-->
<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}}
{{^userName}}
a href="/logout"
a href="/oauth2/authorization/google"
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
index.mustache에 userName을 넘겨줄 수 있도록 IndexController를 수정한다
(SessionUser) httpSession.getAttribute("user")
CustomOAuth2UserService
에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했었다httpSession.getAttribute("user")
로 값을 가져올 수 있다if (user != null)
애플리케이션을 실행시켜 정상 작동하는지 확인한다
현재 로그인된 사용자의 권한은 GUEST(기본)이므로 posts 기능을 쓸 수 없다(403 에러)
h2-console에서 ROLE을 USER로 UPDATE한 뒤 재로그인해서 글이 등록되는지 확인한다
출처: 이동욱 저, 『스프링 부트와 AWS로 혼자 구현하는 웹 서비스』, 프리렉(2019)