구글 로그인 기능을 구현하기 위해서는 구글 클라우드 플랫폼에서 OAuth 클라이언트 ID를 생성해야한다.
구글에 검색하면 정보가 많이 나오니 필요하면 검색해서 생성하도록 하자!
기존에 만들어둔 코드는 github에서 확인할 수 있으니 필요한 분들은 아래 링크 참고하시면 됩니다!
https://github.com/Yelin-park/springboot-aws-practice
GitHub - Yelin-park/springboot-aws-practice: 스프링부트로 웹사이트 만들고 AWS 무중단 배포하기
스프링부트로 웹사이트 만들고 AWS 무중단 배포하기. Contribute to Yelin-park/springboot-aws-practice development by creating an account on GitHub.
github.com
클라이언트 정보를 등록했다는 가정하에 정리해보겠다.
경로는 프로젝트마다 상이할 수 있으니 참고하자!
1. application-oauth 등록과 profile 생성
먼저, application-oauth를 등록해보자!
src/mainresources/ 디렉토리에 application-oauth.properties 파일을 생성하자
생성한 프로퍼티 파일에는 아래와 같이 클라이언트 ID와 클라이언트 보안 비밀코드를 등록한다.
scope는 기본값이 openid, profile, email이다. 강제로 profile,email로 등록한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문이다. 이렇게 되면 OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.
하나의 OAuth2Service로 사용하기 위해 openid scope를 빼고 등록한다.
스프링 부트에서는 properties의 이름을 application-XXX.properties로 만들면 XXX라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.
즉, profile=XXX라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.
호출하는 방식은 여러 방식이 있지만 이번 글에서는 스프링 부트의 기본 설정 파일인 application.properties에서 application-oauth.properties를 포함하도록 구성할 것이다.
application.properties에 다음과 같이 코드를 추가하자
이제 프로젝트 구현을 진행해보자
2. 사용자 정보 도메인 생성 - User
사용자 정보를 담당할 도메인인 User 클래스를 생성하기 위해 패키지는 domain 아래 user 패키지를 생성하여 클래스를 생성하였다.
package com.yaliny.book.springboot.domain.user;
import com.yaliny.book.springboot.domain.posts.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();
}
}
3. 각 사용자 권한을 관리할 Enum 클래스 Role 생성
package com.yaliny.book.springboot.domain.user;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
// 스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 이 앞에 있어야 함
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
4. User의 CRUD를 책임질 UserRepository 생성
package com.yaliny.book.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);
}
5. 스프링 시큐리티 설정하기
1) build.gradle에 스프링 시큐리티 관련 의존성 추가
해당 의존성은 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성이다.
spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.
2) OAuth 라이브러리를 이용한 소셜 로그인 설정 코드 작성하기
config.auth 패키지를 생성 후 SecurityConfig 클래스를 생성하고 다음과 같이 코드를 작성하자
package com.yaliny.book.springboot.config.auth;
import com.yaliny.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/**", "/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-console 화면을 사용하기 위해 해당 옵션들을 disable 하는 것이다.
- authorizeRequest는 URL별 권한 관리를 설정하는 옵션의 시작점이며, authorizeRequest가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
- antMatchers는 권한 관리 대상을 지정하는 옵션이며, URL, HTTP 메소드 별로 관리가 가능하다. "/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었고 "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 설정한 것이다.
- anyRequest는 설정된 값들 이외 나머지 URL들을 나타낸다.여기서는 authenticated()을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 한다. 인증된 사용자 즉, 로그인한 사용자를 나타낸다.
- logout().logoutSuccessUrl("/")은 로그아웃 기능에 대한 여러 설정의 진입점이며, 로그아웃 성공시 / 주소로 이동한다.
- oauth2Login은 OAuth2 로그인 기능에 대한 여러 설정의 진입점
- userInfoEndpoint는 OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
- userService는 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다. 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.
CustomOAuth2UserService 클래스를 만들지 않아서 컴파일 에러가 발생할 수 있는데 바로 코드를 작성하면 된다. 해당 클래스는 구글 로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원한다.
package com.yaliny.book.springboot.config.auth;
import com.yaliny.book.springboot.config.auth.dto.OAuthAttributes;
import com.yaliny.book.springboot.config.auth.dto.SessionUser;
import com.yaliny.book.springboot.domain.user.User;
import com.yaliny.book.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 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);
}
}
해당 클래스의 코드를 조금 설명하자면 아래와 같다.
- registratinId는 현재 로그인 징행 중인 서비스를 구분하는 코드이다. 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버로 로그인하는지 구글로 로그인 하는지 구분하기 위해 사용한다.
- userNameAttributeName은 OAuth2 로그인 징행 시 키가 되는 필드 값을 나타낸다. Primary Key와 같은 의미! 구글의 경우 기본적으로 코드를 지원하지만, 네이버나 카카오 등은 기본 지원하지 않는다. 구글의 기본 코드는 'sub'이다. 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.
- OAuthAttributes는 OAuth2UserServuce를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다. 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용할 것이다.
- SessionUser는 세션에 사용자 정보를 저장하기 위한 DTO 클래스이다.
구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현
사용자의 이름(name)이나 프로필 사진(picture)이 변경되면 User 엔티티에도 반영이 된다.
이제 OAuthAttributes 클래스를 생성하자. 본인은 OAuthAttributes는 DTO로 판단하여 config.auth.dto 패키지를 만들어 해당 패키지에 생성하였다.
package com.yaliny.book.springboot.config.auth.dto;
import com.yaliny.book.springboot.domain.user.Role;
import com.yaliny.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) {
if("naver".equals(registrationId)) {
return ofNaver("id", 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();
}
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
- of()는 OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야 한다.
- toEntity()는 User 엔티티를 생성한다. OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때 이다. 가입할 때의 기본 권한은 GUEST로 주기 위해 role 빌더 값에는 Role.GUEST를 사용했다.
이제 같은 패키지에 SessionUser 클래스를 생성하자.
해당 클래스에는 인증된 사용자 정보만 필요하다. 그 외에 필요한 정보들은 없기 때문에 name, email, prcture만 필드로 선언한다.
package com.yaliny.book.springboot.config.auth.dto;
import com.yaliny.book.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();
}
}
여기서 왜 User 클래스를 사용하지 않는지에 대한 의문이 생긴다.
User 클래스를 사용하여 세션에 저장하려고 하면 User 클래스에 직렬화를 구현하지 않았다라는 의미의 에러가 발생한다.
직렬화 코드를 넣는 것에 많은 생각을 해야한다. User 클래스는 엔티티이기 때문이다.
엔티티 클래스에는 언제 다른 엔티티와 관계가 형성될지 모르기 때문이다.
직렬화 대상에 자식들까지 포함되면 성능 이슈나 부수 효과가 발생할 확률이 높기 때문에 직렬화 기능을 가진 세션 DTO를 하나 추가로 만드는 것이 이후 운영 및 유지보수 때 많은 도움이 된다.
6. 로그인 테스트하기
해당 프로젝트에서는 머스테치를 사용한다.
테스트는 각자 구조에 맞게 맞춰서 설정 후 테스트를 하면 될 것이다!
{{>layout/header}}
<h1>스프링 부트로 시작하는 웹서비스 ver.2</h1>
<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 active" role="button">Google Login</a>
{{/userName}}
</div>
</div>
<br>
<!-- 목록 출력 영역 -->
<table class="table table-horizontal table-bordered">
<thead class="thead-strong">
<tr>
<th>게시글번호</th>
<th>제목</th>
<th>작성자</th>
<th>최종수정일</th>
</tr>
</thead>
<tbody id="tbody">
{{#posts}} <!-- posts라는 List를 순회. Java의 for문이라고 생각~ -->
<tr>
<!-- List에서 뽑아낸 객체의 필드를 사용 -->
<td>{{id}}</td>
<td><a href="/posts/update/{{id}}">{{title}}</a></td>
<td>{{author}}</td>
<td>{{modifiedDate}}</td>
</tr>
{{/posts}}
</tbody>
</table>
</div>
{{>layout/footer}}
- {{userName}}
머스테치는 다른 언어와 같은 if문을 제공하지 않고 true/false만 판단한다. 그래서 머스테치에는 항상 최종값을 넘겨주어야 한다. 여기서도 userName이 있다면 userName을 노출시키도록 구성하였다.
- a href="/logout"
스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다. 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없다~~ SecurityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분하다.
- {{^userName}}
머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.
여기서는 userNmae이 없다면 로그인 버튼을 노출시키도록 구성하였다.
- a href="/oauth2/authorization/google"
스프링 시큐리에서 기본적으로 제공하는 로그인 URL이다.
로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없다.
위에서 구성한 index.mustache에서 userName을 사용할 수 있게 IndexController에서 userName을 model에 저장하는 코드를 추가하자
package com.jojoldu.book.springboot.web;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import javax.servlet.http.HttpSession;
@RequiredArgsConstructor
@Controller
public class IndexController {
/*
build.gradle에서 설정한 머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로와 뒤의 파일 확장자는 자동으로 지정된다.
ex) src/main/resoureces/templates/index.mustache 전환되어 View Resolver가 처리
*/
private final PostsService postsService;
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";
}
@GetMapping("/posts/save")
public String postsSave() {
return "posts-save";
}
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
이제 테스트를 해보면 아래와 같이 화면이 나오고 구글로 로그인을 할 수 있다~
'TIL > Spring' 카테고리의 다른 글
[Spring] 찍어먹어보자! Spring Security와 인증/인가란 무엇인가?(feat. 로그인, 로그아웃, 회원가입 간단 구현) (0) | 2025.02.02 |
---|---|
[TIL/Spring] SpringBoot JUnit을 사용한 간단한 단위테스트(feat. 단위테스트와 TDD란 다른 것) (0) | 2023.02.15 |
[TIL] 비즈니스 로직을 처리하는 곳은 Service가 아니다? (0) | 2022.10.09 |
[TIL] Spring Framework - HandlerInterceptorAdapter (0) | 2022.09.28 |
[TIL] 의존관계 자동 주입과 조회 빈이 2개 이상일 때 문제 해결하기, 조회한 빈이 모두 필요할 때 사용하는 법 (0) | 2022.08.19 |