스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 1장
스프링 시큐리티는 막강한 인증과 인가 기능을 가진 프레임워크이다.
사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 보면 된다.
인터셉터, 필터 기반의 보안 기능을 구현하는 것보다 스프링 시큐리티를 통해 구현하는 것을 적극적으로 권장한다.
스프링의 대부분 프로젝트들처럼 확장성을 고려한 프레임워크다 보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있다.
스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트
spring-security-oauth2-autoconfigure 라이브러리를 사용할 경우 스프링 부트 2에서도 1.5에서 쓰던 설정을 그대로 사용할 수 있다.
스프링 부트 1.5 방식에서는 url 주소를 모두 명시해야 하지만, 2.0 방식에서는 client 인증 정보만 입력하면 된다.
1.5 버전에서 직접 입력했던 값들은 2.0 버전으로 오면서 모두 enum으로 대체되었다.
CommonOAuth2Provider라는 enum이 새롭게 추가되어 구글, 깃허브, 페이스북, 옥타의 기본 설정값은 모두 여기서 제공한다.
구글 서비스 등록
먼저 구글 서비스에 신규 서비스를 생성해야 한다.
여기서 발급된 인증 정보를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으니 무조건 발급받고 시작해야 한다.
https://console.cloud.google.com/
Google 클라우드 플랫폼
로그인 Google 클라우드 플랫폼으로 이동
accounts.google.com
구글 로그인을 하고 새로운 프로젝트를 만든 후 아래의 이미지대로 따라하면 된다.
앱 이름과 사용자 지원 이메일, 개발자 연락처 정보는 필수로 입력해야 한다.
아래의 이미지처럼 Google API의 범위를 지정할려면 검색창에 email, profile, openid를 입력하고 체크한다.
동의 화면 구성이 끝났으면 화면 제일 아래에 저장 버튼을 클릭하고 다음 이미지와 같이 OAuth 클라이언트 ID 만들기 화면으로 바로 이동한다.
사용자 인증 정보 > OAuth 클라이언트 ID를 클릭한다.
승인된 라디렉션 URI 항목만 아래 URL를 등록한다.
http://localhost:8080/login/oauth2/code/google
승인된 라디렉션 URI
- 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL이다.
- 스프링 부트 2버전의 시큐리티에서는 기본적으로 {도메인}/login/auth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있다.
- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다.
만들기를 클릭한 뒤 사용자 인증 정보에서 확인 하면 아래의 이미지처럼 표시될 것이다.
4장에서 만들었던 application.properties가 있는 src/main/resources/ 디렉토리에 application-oauth.properties 파일을 생성한다.
spring.security.oauth2.client.registration.google.client-id=1022330919558-4m6k3tjrjpse2m0hu3jbcdbjge4knhq2.apps.googleusercontent.com
spring.security.oauth2.client.registration.google.client-secret=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
spring.security.oauth2.client.registration.google.scope=profile, email
.gitignore 등록
구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들이다.
이들이 외부에 노출될 경우 언제든 개인정보를 가져갈 수 있는 취약점이 될 수 있다.
깃허브에 application-oauth.properties 파일이 올라가는 것을 방지하겠다.
application-ouauth.properties
구글 로그인 연동하기
구글의 로그인 인증정보를 발급 받았으니 프로젝트 구현을 진행하겠다.
먼저 사용자 정보를 담당할 도메인인 User 클래스를 생성한다.
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 update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
(1) @Emumerated(EnumType.STRING)
- JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할 지를 결정한다.
- 기본적으로는 int로 된 숫자가 저장된다.
- 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다.
- 그래서 문자열로 저장될 수 있도록 선언한다.
각 사용자의 권한을 관리할 Enum 클래스 Role를 생성한다.
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;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.
마지막으로 user의 CRUD를 책임질 UserRepository도 생성한다.
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
(1) findByEmail
- 소셜 로그인으로 반환되는 값 중 email를 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드이다.
User 엔티티 관련 코드를 모두 작성했으니 본격적으로 시큐리티 생성을 진행하겠다.
스프링 시큐리티 설정
먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나를 추가한다.
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
(1) spring-boot-starter-oauth2-client
- 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성이다.
- spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.
build.gradle 설정이 끝났으면 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.
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
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);
}
}
(1) @EnableWebSecurity
- Spring Security 설정들을 활성화시켜 준다.
(2) csrf( ).disable( ).headers( ).frameOptions( ).disable( )
- h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
(3) authorizeRequests
- URL 별 권한 관리를 설정하는 옵션의 시작점이다.
- authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
(4) antMatchers
- 권한 관리 대상을 지정하는 옵션이다.
- URL, HTTP 메소드별로 관리가 가능하다.
- "/" 등 지정된 URL들은 permitAll( ) 옵션을 통해 전체 열람 권한을 주었다.
- "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.
(5) anyReqeust
- 설정된 값들 이외 나머지 URL들을 나타낸다.
- 여기서는 authenticated( )을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 한다.
- 인증된 사용자 즉, 로그인한 사용자들을 이야기한다.
(6) logout( ).logoutSuccessUrl("/")
- 로그아웃 기능에 대한 여러 설정의 진입점이다.
- 로그아웃 성공 시 / 주소로 이동한다.
(7) oauth2Login
- OAuth 2 로그인 기능에 대한 여러 설정의 진입점이다.
(8) userInfoEndpoint
- OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
(9) userService
- 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다.
- 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.
설정 코드 작성이 끝났다면 CustomOAuth2UserService 클래스를 생성한다.
import com.jojoldu.book.springboot.domain.user.User;
import com.jojoldu.book.springboot.domain.user.UserRepository;
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 javax.servlet.http.HttpSession;
import java.util.Collections;
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);
}
}
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;
@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);
}
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();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
import com.jojoldu.book.springboot.domain.user.User;
import lombok.Getter;
import java.io.Serializable;
@Getter
public class SessionUser implements Serializable {
String name;
String email;
String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
SessionUser에는 인증된 사용자 정보만 필요하다. 그 외에 필요한 정보들은 없으니 name, email, picture만 필드로 선언
로그인 테스트
index.mustache에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드이다.
{{#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}}
IndexController에서 userName을 model에 저장하는 코드를 추가한다.
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";
}