스프링 시큐리티를 이용해 로그인/로그아웃 기능을 구현
스프링 시큐리티에서 UserDetailService
인터페이스는 DB에서 회원 정보를 가져오는 역할을 담당한다는 것을 알게 되었다. 따라서 이를 구현하는 클래스를 만들어 로그인 기능을 구현했다. 이전에 만들었던 MemberService
로 UserDetailsService
를 구현해보았다.
MemberService.java
package com.shop.service;
import com.shop.entity.Member;
import com.shop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
// ...코드 생략
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { // UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩. 로그인할 유저의 email을 파라미터로 받음.
Member member = memberRepository.findByEmail(email);
if(member == null) {
throw new UsernameNotFoundException(email);
}
return User.builder() // UserDetail을 구현하고 있는 User 객체를 반환해줌. User 객체를 생성하기 위해 생성자로 회원의 이메일, 비밀번호, role을 파라미터로 넘겨줌.
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().toString())
.build();
}
}
시큐리티 설정을 위해 SecurityConfig 설정도 해주었다.
SecurityConfig.java
package com.shop.config;
import com.shop.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig {
@Autowired
MemberService memberService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/members/login") // 로그인 페이지 URL
.defaultSuccessUrl("/") // 로그인 성공 시 이동할 URL
.usernameParameter("email") // 로그인 시 사용할 파라미터 이름
.failureUrl("/members/login/error") // 로그인 실패 시 이동할 URL
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) // 로그아웃 URL
.logoutSuccessUrl("/") // 로그아웃 성공 시 이동할 URL
;
}
그런데, 이렇게 폼으로 로그인을 구현하고 보니 뭔가 허전했다. 요즘은 구글 로그인 혹은 네이버 아이디로 로그인하는 방식과 같은 Social Login을 많이 사용하는 추세이다. 따라서 나는 이를 구현하고 싶었다. 이를 위해 열심히 구글링을 하고 공부를 했다.
OAuth2는 다음과 같은 방식으로 동작한다.
( 출처 : https://about-tech.tistory.com/entry/Security-OAuth-20%EB%9E%80-%EA%B0%9C%EB%85%90-%EC%9D%B8%EC%A6%9D%EB%B0%A9%EC%8B%9D)
사용자가 특정 어플리케이션에서 OAuth 서비스를 요청한다. 웹 어플리케이션(Client)에서는 OAuth 서비스에 Authorization Code를 요청한다.
OAuth 서비스는 Redirect를 통해 Client에게 Authorization Code를 부여한다.
Client는 Server에게 OAuth 서비스에서 전달받은 Authorization Code를 보낸다.
Server는 Authorization Code를 다시 OAuth 서비스에 전달해 Access Token을 전달받는다.
Server는 Access Token으로 Client를 인증하고 요청에 대한 응답을 반환한다.
이처럼 OAuth 2.0 방식은 기존 인증방식과 다르게 인증을 중개하는 방식이다. 이미 사용자 정보를 가지고 있는 소셜 서비스(Google, Naver, Kakao, Github 등)에서 인증을 대신 진행해주고, 접근 권한에 대해 토큰을 발급한 후 이를 통해 웹 서버에서 인증을 가능하게 하는 것이다. 소셜 서비스가 인증(Authentication)을 대신 해주는 셈이다.
OAuth2 구글 로그인 방식을 구현하기 위해서는 다음의 로직들이 필요했다.
DefaultOAuth2UserService
를 상속해서 만든 커스텀 OAuth2UserService
UserDetails
메서드에 실제 OAuth2User
구현SecurityConfig
설정MemberService
에서 📌 loadUserByUsername
메서드를 구현하여 회원을 찾도록 함.MemberController
에서 UserDetail
Form
로그인이면 UserDetails
,OAuth2
로그인이면 OAuth2User
타입으로 반환📌
loadUserByUsername
OAuth2로 로그인 하는 경우, 사용자가 직접 회원가입을 하지 않는다. 따라서loadUserByUsername
이라는 메소드를 구현하여 먼저 회원을 찾고,
없는 회원이라면 회원가입 처리하는 로직을 구현해주어야한다.
// ...import 생략
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
@Autowired
private MemberRepository memberRepository;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String provider = userRequest.getClientRegistration().getRegistrationId(); //google
String providerId = oAuth2User.getAttribute("sub");
String name = provider+"_"+providerId;
// 사용자가 입력한 적은 없지만 만들어준다
String uuid = UUID.randomUUID().toString().substring(0, 7);
String password = bCryptPasswordEncoder.encode("패스워드"+uuid);
// 사용자가 입력한 적은 없지만 만들어준다
String email = oAuth2User.getAttribute("email");
Role role = Role.SOCIAL;
Member byUsername = memberRepository.findByEmail(email);
//DB에 없는 사용자라면 회원가입처리
if(byUsername == null){
byUsername = Member.oauth2Register()
.password(password).email(email).role(role)
.provider(provider).providerId(providerId)
.build();
memberRepository.save(byUsername);
}
return new PrincipalDetails(byUsername, oAuth2User.getAttributes());
}
}
OAuth2
user 정보를 받아올 수 있음.MemberRepository
에 save하는 방식으로 회원가입 처리 가능.@Getter
@ToString
public class PrincipalDetails implements UserDetails, OAuth2User {
private Member member;
private Map<String, Object> attributes;
//UserDetails : Form 로그인 시 사용
public PrincipalDetails(Member member) {
this.member = member;
}
//OAuth2User : OAuth2 로그인 시 사용
public PrincipalDetails(Member member, Map<String, Object> attributes) {
//PrincipalOauth2UserService 참고
this.member = member;
this.attributes = attributes;
}
/**
* UserDetails 구현
* 해당 유저의 권한목록 리턴
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return member.getRole().toString();
}
});
return collect;
}
/**
* UserDetails 구현
* 비밀번호를 리턴
*/
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
/**
* OAuth2User 구현
* @return
*/
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
/**
* OAuth2User 구현
* @return
*/
@Override
public String getName() {
String sub = attributes.get("sub").toString();
return sub;
}
}
UserDetalis, OAuth2User를 구현한 객체
public Collection<? extends GrantedAuthority> getAuthorities()
메서드를 사용. 이는 SecurityFilterChain
에서 권한을 체크할 때 사용하는 것.@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig {
@Autowired
MemberService memberService;
@Autowired
private PrincipalOauth2UserService principalOauth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/members/login") // 로그인 페이지 URL
.defaultSuccessUrl("/") // 로그인 성공 시 이동할 URL
.usernameParameter("email") // 로그인 시 사용할 파라미터 이름
.failureUrl("/members/login/error") // 로그인 실패 시 이동할 URL
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) // 로그아웃 URL
.logoutSuccessUrl("/") // 로그아웃 성공 시 이동할 URL
.and() // OAuth
.oauth2Login()
.defaultSuccessUrl("/")
.userInfoEndpoint() // OAuth2 로그인 성공 후 가져올 설정들
.userService(principalOauth2UserService); // 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
;
구글 로그인이 완료된 뒤의 후처리를 여기서 해준다.
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
private final MemberRepository memberRepository;
// 회원정보 저장, 중복회원 검사 메소드 생략
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { // UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩. 로그인할 유저의 email을 파라미터로 받음.
Member member = memberRepository.findByEmail(email);
if(member == null) {
throw new UsernameNotFoundException(email);
}
return User.builder() // UserDetail을 구현하고 있는 User 객체를 반환해줌. User 객체를 생성하기 위해 생성자로 회원의 이메일, 비밀번호, role을 파라미터로 넘겨줌.
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRole().toString())
.build();
}
}
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController { // 회원가입을 위한 컨트롤러
@Autowired
private MemberRepository memberRepository;
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
// .. 기존 로그인 매핑 생략 (/new, /login, /login/error)
// !!!! OAuth로 로그인 시 이 방식대로 하면 CastException 발생함
@GetMapping("/form/loginInfo")
@ResponseBody
public String formLoginInfo(Authentication authentication, @AuthenticationPrincipal PrincipalDetails principalDetails){
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
String member = principal.getUsername();
System.out.println(member);
String user1 = principalDetails.getUsername();
System.out.println(user1);
return member.toString();
}
@GetMapping("/oauth/loginInfo")
@ResponseBody
public String oauthLoginInfo(Authentication authentication, @AuthenticationPrincipal OAuth2User oAuth2UserPrincipal){
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
System.out.println(attributes);
// PrincipalOauth2UserService의 getAttributes내용과 같음
Map<String, Object> attributes1 = oAuth2UserPrincipal.getAttributes();
// attributes == attributes1
return attributes.toString(); //세션에 담긴 user가져올 수 있음
}
@GetMapping("/loginInfo")
@ResponseBody
public String loginInfo(Authentication authentication, @AuthenticationPrincipal PrincipalDetails principalDetails){
String result = "";
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
if(principal.getName() == null) {
result = result + "Form 로그인 : " + principal;
}else{
result = result + "OAuth2 로그인 : " + principal;
}
return result;
}
}
"/form/loginInfo"
, "/oauth/loginInfo"
에 따라 다르게 로그인할 수 있게됨.