- 로그인 시 보안
- 비밀번호 찾기
- 비밀번호 변경
- 회원정보 변겨
- 회원가입 시 이메일 || 전화번호 인증
등의 기능들을 네이버, 페이스북, 구글, 등에 맡기면 되기 때문에, 서비스 개발에 집중할 수 있다.
spring-security-oauth2-autoconfigure
는 스프링 1.5에서 사용되던 라이브러리인데 실습 중인 책에서는 Spring Security OAuth2 Client
를 사용한다.
이유는 총 3가지로,
1.5에서는 url 주소를 모두 명시해야 하지만 2.0에서는 클라이언트 인증 정보만 입력하면 된다.
2.0에서는 enum(
CommonOAuthProvider
)으로 대체 되었기 때문이다.
(구글 서비스)[console.cloud.google.com]에 접속해서 순서에 따라 클라이언트 ID를 생성한다.
프로젝트의 resources에 application-oauth.properties
생성 후 클라이언트 ID, 클라이언트 보안 비밀, 스코프를 지정한다.
spring.security.oauth2.client.registration.google.client-id= 클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret= 클라이언트 SECRET
spring.security.oauth2.client.registration.google.scope = profile, email
profile, email의 스코프를 강제로 지정하는 이유
openid라는 스코프가 있으면 Open id Provider로 인식하기 때문이다.
이렇게 되면 OpenId Provider인 서비스와 그렇지 않은 서비스를 나눠서 각각 서비스를 만들어야 한다. 하나의 서비스로 사용하기 위해 openid를 제외한 나머지 스코프를 등록한다.
스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성 되어, 이를 통해 관리 가능하다.
ex) profile = xxx
이런 방식으로 불러오면 해당 properties의 설정을 가져온다.
application-oauth.properties
(클라이언트 id, 보안 비밀 작성 파일)항목을 작성해야 한다. # Project exclude paths
/.gradle/
.idea
++ application-oauth.properties
User.class
package com.seung.booboo.springboot.domain.user; import com.seung.booboo.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 private Role role; @Builder public User (Long id, String name, String email, String picture, Role role) { this.id = id; 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(); } }
사용자의 권한을 관리하는 Enum 타입의 Role이 추가된다.
Role.class
package com.seung.booboo.springboot.domain.user; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor public enum Role { GUEST("ROLE_GUEST", "손님"), USER("ROLE_USER", "일반 사용자"); // spring security에서는 권한 코드에 ROLE_이 앞에 있어야만 한다. private final String key; private final String title; }
- 스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 앞에 있어야 한다.
package com.seung.booboo.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);
}
Optional<User> findByEmail(String email)
- 사용자가 처음 가입 || 이미 등록된 상태인지 파악하기 위한 메소드
스프링 시큐리티 설정
build.gradle 의존성 추가
:compile('org.springframework.boot:spring-boot-starter-oauth2-client')
- 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현시 필요config.path > SecurityConfig 클래스 생성
CustomOAuth2UserService 클래스 생성 (로그인 이후 가져온 사용자의 정보를 기반으로 가입 및 정보수정 세션 저장등의 기능 지원)
package com.seung.booboo.springboot.config.auth;
import com.seung.booboo.springboot.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable().headers().frameOptions().disable() //h2-console 사용하기 위해 해당 옵션 disable
.and()
.authorizeRequests().antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
// authorizeRequests() > URL 별 권한 관리 설정 옵션 시작점, 선언되야만 andMatchers 사용가능
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
// andMathcer > 권한관리 대상 지정 URL, HTTP 별로 관리 가능
.anyRequest().authenticated()
// 그 외 URL은 인증된(로그인된) 유저만 사용 가능
.and()
.logout().logoutSuccessUrl("/")
// 로그아웃 성공시 url
.and()
.oauth2Login()
// oauth2 로그인 기능 설정의 진입점
.userInfoEndpoint()
// 사용자 정보를 가져올 때의 설정 담당
.userService(customOAuth2UserService);
// 로그인 성공시 후속 조치를 진행할 인터페이스의 구현체 등록함.
}
}
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService {
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);
}
}
registraionId
: 로그인 진행중인 서비스를 구분하는 코드 (구글 || 네이버 || 카카오 등 어느 소셜의 로그인인지 구분)userNameAttributeName
: PK가 될 필드 값. (구글만 기본 코드인 sub
를 제공, 네이버, 카카오는 지원하지 않음 )OAuthAttributes
생성 : OAuthUserService를 통해 가져온 OAuthUser의 attribute를 담을 클래스. @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 registraionId, String userNameAttributeName, Map<String, Object> attributes) {
//OAuth2User 에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환
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();
}
}
SessionUser
DTO 생성 @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();
}
}
: Entity Class에는 언제 다른 Entity와 관계가 형성될지 모른다. 성능 이슈 및 부수 효과가 발생할 확률이 높아진다.때문에 SessionUser DTO를 새로 생성한다. User클래스는 Entity이기 때문에 직렬화를 구현하지 않았기 때문이다.
- index.mustache 수정
.
.
.
<h1>스프링 부트로 시작하는 웹 서비스</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">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/userName}}
</div>
</div>
<br>
{{#userName}}
: true/false를 판단하는 머스테치에는 최종값을 넘겨줘야 한다. userName이 있다면 userName을 노출시킨다.
a href="/logout"
: spring security에서 제공하는 URL이다. 로그아웃 기능을 컨트롤러 구현 없이 지원한다.
{{^userName}}
: userName이 없다면 로그인 Google Login
버튼을 노출시킨다.
a href="/oauth2/authorization/google"
: spring security에서 제공하는 로그인 URL. 마찬가지로 컨트롤러를 생성할 필요가 없다.
- indexController.class / index 메소드 수정
@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";
}
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> config/oauth 패키지에 @LoginUser 어노테이션 생성 및 SessionUser 중복 제거
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if(user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER) // 어노테이션 생성될 수 있는 위치
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser { // 어노테이션 클래스 지정.
}
- LoginUserArgumentResolver 생성 :
HandlerMethodArgumentResolver
인터페이스를 구현한 클래스.
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
@Override
public boolean supportsParameter(MethodParameter parameter) { // 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true
boolean isLoginUserAnnotaion = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotaion && isUserClass;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// 파라미터에 전달할 객체 생성. 여기서는 세션에서 객체를 가져옴.
return httpSession.getAttribute("user");
}
}
supportsParameter() : 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단(@LoginUser
어노테이션이 있고 파라미터 클래스 타입이 SessionUser.class인 경우 true)
resolveArgument() : 파라미터에 전달할 객체를 생성(세션에서 객체를 가져온다)
8 ~ 9 를 통해 @LoginUser
를 사용하기 위한 환경 구성이 끝났다.
- WebConfig.class 생성 : 스프링에서 인식될 수 있도록 한다.
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
// HandlerMethodArgumentResolver는 항상 WebMvcConfigure의 addArgumentResolvers()를 통해 추가해야 함.
// 다른 Handler-MethodArgumentResolver가 필요할 때 같은 방식으로 추가해주면 됨.
argumentResolvers.add(loginUserArgumentResolver);
}
}
스프링 부트도 처음인데 스프링 시큐리티도 처음이라 뭐가 뭔진 모르겠지만 일단 끝내자. 끝까지 마무리 해보고 책을 다시 봐도 안 늦는다.