스프링 시큐리티는 막강한 인증과 인가 기능을 가진 프레임워크이다. 스프링은 인터셉터, 필터 기반의 보안 기능을 구현하는 것보다 스프링 시큐리티를 통해 구현하는 것을 적극 권장하고 있다.
이번 프로젝트에서는 프로젝트 자체 로그인을 구현하는 것이 아닌 OAuth2.0을 이용해 로그인을 구현해 볼 것이다.
이 책에서는 스프링 부트 2.0을 기준으로 구현했다. 하지만 나는 스프링 부트 3.0을 기준으로 구현할려고 한다.
스프링 부트 1.5 VS 스프링 부트 2.0
OAuth2 연동 방법이 1.5에 비해 2.0에서 많이 달라졌다고 한다. 내가 설계한 프로젝트의 스프링 부트 버전은 3.0이다. 2.0과 OAuth2 에서는 크게 변경되지는 않았지만 SecurityConfig를 다르는 부분이 달라졌다.
만약 스프링 부트 1.5에서 사용하던 방식대로 스프링 부트 2.0에서 사용하고 싶으면
spring-security-oauth2-autoconfigure
이 라이브러리를 사용하면 된다.
이번 프로젝트에서 사용할 것은
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
스프링 부트용 라이브러리이다.
기존에 사용되던 방식은 확장 포인트가 적절하게 오픈되어 있지 않아 직접 상속하거나 오버라이딩 해야 하고 신규 라이브러리의 경우 확장 포인트를 고려해서 설계된 상태이다.
또 차이점은 스프링 부트 1.5에서는 url 주소를 모두 명시해야 하지만, 2.0 방식에는 client 인증 정보만 입력하면 된다.
모두 enum으로 대체되었다. 바로 CommonOAuth2Provider이 존재한다. 이것을 통해 구글, 깃허브, 페이스북, 옥타의 기본 설정값은 모두 여기서 제공한다.
구글 서비스 등록
제일 먼저 구글 cloud platform에서 프로젝트를 생성하고 API 및 서비스에서 사용자 인증 정보를 생성해야 한다.
OAuth 클라이언트 ID를 만들고 동의 화면을 만든다. 이 때, Google API 범위를 email, profile, openid를 선택한다.
그 후 OAuth 클라이언트 ID를 만들면 된다. 이 프로젝트는 웹 프로젝트이기 때문에 웹 어플리케이션을 선택했다.
이제 중요한 부분이 나온다. 바로 승인된 리디렉션 URI를 적어야 한다.
승인된 리디렉션 URI
- 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL 이다.
- 스프링 부트 2 버전의 시큐리티에서 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있다.
- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없다. 시큐리티에서 이미 구현해 놓은 상태이다.
이렇게 하면 cliend-id와 client-secret을 알 수 있다. 이제 이 내용을 application-oauth.properties에 추가해서 사용한다.
스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다. spring.profiles.include=oauth 이런 식으로 호출해 해당 properties의 설정들을 가져올 수 있다!!
.gitignore 등록
application-oauth.properties에 관한 내용은 다른 사람들이 절대로 확인하면 안된다. 그러므로 깃허브에 올라가면 안된다.
인텔리제이에서 스프링으로 만들면 자동으로 .gitignore이 생성된다.
!application-oauth.properties
*.properties
*.yml
*.json
resources
이렇게 추가하면 application-oauth.properties는 깃허브에 올라가지 않는다.
구글 로그인 연동하기
일단 로그인 하기 위해서는 기본으로 User엔티티가 필요하다. 책에서는 User 엔티티로 만들었다. 하지만 User라는 이름을 상용하면 H2Database에서 사용이 불가능하다. 이미 H2에는 User가 내장되어 있다고 한다. 그러므로 Member로 만들었다.
- Member Entity
@Getter
@NoArgsConstructor
@Entity
public class Member 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 Member(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public Member update(String name, String picture){
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey(){
return this.role.getKey();
}
}
여기서는 Enumerated를 통해 Enum을 사용한다.
- Role
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST","손님"),
USER("ROLE_USER","일반 사용자");
private final String key;
private final String title;
}
Guest와 User가 존재한다. Guest일 경우 게시글을 못쓰게 할거기 때문에 만들었다.
스프링 시큐리티 설정
이 책에서는 WebSecurityConfigurerAdapter를 extends해서 사용했다. 하지만 스프링 부트 최신 버전에서는 WebSecurityConfigurerAdapter가 deprecated 되었다. 그러므로 책과는 다른 방식으로 SecurityConfig를 구현해야 한다.
이제 부터 스프링 시큐리티는 SecurityFilterChain을 사용해 빈으로 등록해야 한다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig{
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests(authorize -> authorize
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated())
.logout(logout -> logout
.logoutSuccessUrl("/"))
.oauth2Login(oauth2Login -> oauth2Login
.userInfoEndpoint()
.userService(customOAuth2UserService));
return http.build();
}
}
@EnableWebSecurity
- Spring Security 설정들을 활성화시켜 준다.
.csrf().disable().headers().frameOptions().disable()
- h2-console 화면을 사용하기 위해 해당 옵션들을 disable 해야한다.
- csrf에 대해서는 한번 더 확실하게 공부해 봐야겠다.
authorizeRequests
- URL별 권한 관리를 설정하는 옵션의 시작점이다.
- authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
antMatchers
- 권한 관리 대상을 지정하는 옵션이다.
- URL, HTTP 메소드별로 관리가 가능하다.
- "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 준다.
- "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 사용할 수 있게 한다.
anyRequest
- 설정된 값들 이외 나머지 URL들을 나타낸다.
- authenticated()을 추가하여 나머지 URL들을 모두 인증된 사용자들에게만 허용하게 한다.
logout().logoutSuccessUrl("/")
- 로그아웃 기능에 대한 여러 설정의 진입점이다.
- 로그아웃 성공 시 / 주소로 이동한다.
oauth2Login
- OAuth2 로그인 기능에 대한 여러 설정의 진입점이다.
userInfoEndpoint
- OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
userService
- 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록하다.
- 리소스 서버(소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.
CustomOAuth2UserService
@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());
Member user = saveOrUpdate(attributes);
SessionUser sessionUser = new SessionUser(user);
httpSession.setAttribute("user", sessionUser);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
}
private Member saveOrUpdate(OAuthAttributes attributes){
Member user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
loadUser는 OAuth2UserService를 implements 하면 loadUser를 Override를 해야한다. loadUser는 유저의 정보를 가져온다.
registrationId
- 현재 로그인 진행 중인 서비스를 구분하는 코드이다.
- 지금은 구글만 사용해서 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용한다.
userNameAttributeName
- OAuth2 로그인 진행 시 키가 되는 필드값을 이야기한다. Primary Key와 같은 의미이다.
- 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않는다.
- 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.
OAuthAttributes
- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.
SessionUser
- 세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.
OAuthAttributes
@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 Member toEntity() {
return Member.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
OAuthAttributtes는 dto로 받아올 정보를 저장한다.
of
- OAuth2User에서 받아온 정보는 모두 Map이기 때문에 값을 변환해줘야 한다.
toEntity()
- Member Entity를 생성한다.
- 이 엔티티가 생성되는 시점은 처음 가입했을 때 이다.
SessionUser
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(Member user){
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
SessionUser에는 인증된 사용자 정보만 필요하다.
Serializable은 뭘까?
우리가 만든 클래스가 파일에 읽거나 쓸 수 있도록 하거나, 다른 서버로 보내거나 받을 수 있도록 하려면
반드시 이 인터페이스를 구현해야 한다.
즉, Serializable 인터페이스를 구현하면 JVM에서 해당 객체는 저장하거나 다른 서버로 전송할 수 있도록 해준다.
Member 클래스를 사용하면 어떻게 될까?
Member 클래스에 직렬화를 구현하지 않았다는 에러가 발생한다. Member class에 직렬화 코드를 넣으면 해결된다. 하지만 엔티티 클래스는 언제 다른 엔티티와 관계를 형성될지 모르므로 성능 이슈, 부수 효과가 발생할 확률이 높다. 그래서 직렬화 기능을 가진(Serializable) 세션 Dto를 하나 추가해야 한다.
그럼 직렬화는 무엇일까?
자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트(byte) 형태로 데이터 변환하는 기술과 바이트로 변환된 데이터를 다시 객체로 변환하는 기술(역직렬화)을 아울러서 이야기한다.
로그인 테스트
이제 책에서 주어준 mustache 코드를 사용해 직접 구글 로그인을 시험 해볼려고 한다.
구글 로그인 버튼이 생겼다. 로그인 버튼을 누르면 구글 로그인 창이 뜬다. 그 후 구글 로그인을 하면
이렇게 내가 로그인 중이라고 뜬다. DB를 확인해보면
이렇게 나에 대한 정보가 들어온 것을 확인할 수 있다. 지금 이상태에서 글을 등록할려고 하면
이런 에러가 발생한다. 지금 나에 상태가 GUEST이기 때문이다. 아까 USER만이 글을 등록할 수 있게 만들었다.
'토이프로젝트 > 스프링 부트로 구현한 웹' 카테고리의 다른 글
세션 저장소로 데이터베이스 사용하기 (0) | 2023.03.09 |
---|---|
구글 로그인 어노테이션 기반으로 개선하기 (0) | 2023.03.09 |
머스테치로 화면 구성하기(3) 수정, 삭제 (0) | 2023.03.03 |
머스테치로 화면 구성하기(2) 등록, 조회 (0) | 2023.03.03 |
머스테치로 화면 구성하기(1) (0) | 2023.03.03 |