이 글은 책 「스프링 부트와 AWS로 혼자 구현하는 웹 서비스」를 공부하고 정리한 글입니다.
오늘은 구글 로그인을 프로젝트에 적용해보자!
User 클래스는 사용자 정보를 담당할 도메인이다.
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)
EnumType.STRING
로 저장될 수 있도록 선언한다.각 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다.
domain/user/Role.java
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_
이 앞에 있어야만 한다.
그래서 코드별 키 값을 ROLE_GUEST
, ROLE_USER
등으로 지정한다.
User의 CRUD를 책임질 UserRepository를 생성하자.
domain/user/Repository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
findByEmail
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
spring-boot-starter
spring-security-oauth2-client
와 spring-security-oauth2-jose
를 기본으로 관리해준다.OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성해보자.
config/authSecurityConfig.java
@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()
disable
한다.authorizeRequests
authorizeRequests
가 선언되어야만 antMatchers
옵션을 사용할 수 있다.antMatchers
"/"
등 지정된 URL들은 permitAll()
옵션을 통해 전체 열람 권한을 주었다."/api/v1/**"/
주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.anyRequest
authenticated()
을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 한다.logout().logoutSuccessUrl("/")
/
주소로 이동한다.oauth2Login
userInfoEndpoint
userService
구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원한다.
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<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
"sub"
이다.OAuthAttributes
SessionUser
구글 사용자 정보가 업데이트 되었을 때의 대비하여 update 기능도 같이 구현되었다. 사용자의 이름이나 프로필 사진이 변경되면 User 엔티티에도 반영된다.
config/auth/dto/OAuthAttributes.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;
}
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();
}
}
toEntity()
GUEST
로 주기 때문에 role 빌더값에는 Role.GUEST
를 사용한다.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 dto를 만드는 이유는?
User 클래스를 그대로 사용하면 직렬화를 구현하지 않았다는 의미의 에러가 발생하게 된다.
오류를 해결하기 위해 User 클래스에 직렬화 코드를 넣기에는 User 클래스가 엔티티이기 때문에 좋은 방법이 아니다. 엔티티가 만약 자식 엔티티를 가지고 있다면 직렬화 대상에 자식들까지 포함되어 성능 이슈, 부수 효과가 발생할 확률이 높기 때문이다.
그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.
스프링 시큐리티가 잘 적용되었는지 확인하기 위해 로그인 버튼을 추가해보자.
...
<a href="/post/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}}
...
{{#userName}}
(if userName != null 등)
을 제공하지 않고, true/false 여부만 판단한다.userName
이 있다면 userName
을 노출시키도록 구성했다.a href="/logout"
{{^userName}}
^
를 사용한다.userName
이 없다면 로그인 버튼을 노출시키도록 구성했다.a href="/oauth2/authorization/google"
index.mustache에서 userName
을 사용할 수 있게 IndexController에서 userName
을 model
에 저장하는 코드를 추가하자.
public class IndexController {
...
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model) {
model.addAttribute("post", postService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user");
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
...
}
(SessionUser) httpSessions.getAttribute("user")
httpSession.getAttribute("user")
에서 값을 가져올 수 있다.if (user != null)
model
에 userName
으로 등록한다.model
에 아무런 값이 없는 상태이니 로그인 버튼이 보이게 된다.Google Login
버튼이 나타난다. 버튼을 클릭하면 구글 계정을 선택할 수 있다.
로그인이 성공하면 구글 계정에 등록된 이름이 화면에 노출된다.
회원 가입이 잘 되었는지 확인하기 위해 h2-console에 접속해서 USER 테이블을 확인해보자.
ROLE
에 GUEST
가 저장되어 있는 것을 확인할 수 있다.
등록
버튼을 클릭하면 다음가 같이 403(권한 거부)
에러가 발생한 것을 볼 수 있다.
현재 로그인된 사용자의 권한이 GUEST
이므로 post 기능을 전혀 쓸 수 없기 때문에 에러가 발생하는 것이다.
h2-console에 접속해서 ROLE
을 USER
로 변경해보자.
update user set role = 'USER';
세션에는 이미 GUEST인 정보로 저장되어있으니 로그아웃 후 다시 로그인하여 세션 정보를 최신 정보로 갱신한 후 글 등록을 해보자.
이번에는 정상적으로 글이 등록된 것을 확인할 수 있다.
다음 시간에는 기능 개선을 해보도록 하겠다.