저번 velog에서 스프링부트 oauth2를 이용한 구글 로그인 기초 설정들에 대해 알아봤는데요. SpringBoot Oauth2 Google Login(Oauth2를 이용한 구글 로그인)
이번 velog에는 코드로 어떻게 구현하는지 알아가보면 좋을거 같습니다.
하기전에 잠깐 ✋
Spring에 있는 프레임워크로 Security Filter로 인증(authentication), 인가(authorization)을 쉽게 할 수 있도록 도와주는 프레임워크
프런트엔드 없이 백엔드만 만드는 관계로 UI는 mustache 간단하게 합시다:)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>LoginForm</title>
</head>
<body>
<form action="/login" method="post">
<input type="text" name="username" placeholder="Username"> <br />
<input type="password" name="password" placeholder="Password"> <br />
<button type="submit">Login</button>
</form>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/joinForm">회원가입 아직 안하셨나요?</a>
</body>
</html>
저번 시간에 말했듯이 프론트엔드 쪽에서 구글로그인 버튼을 누르면 /oauth2/authorization/google
로 넘어오게 해야 합니다.
Oauth2로 로그인하는데, 세큐리티 사용하고 싶다? -> gradle에서
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
세큐리티 설정은 필수적입니다.
gradle 설정 완료 후,
config 폴더 생성, 안에 SecurityConfig.java 생성합니다.
(Spring Security ver.3입니다)
@Configuration
@EnableWebSecurity //security 지원 활성화
@EnableMethodSecurity(securedEnabled = true) //controller위에 secured 어노테이션 사용 가능하게 만듦
public class SecurityConfig{
@Autowired
private PrincipalOauth2UserService principalOauth2UserService;
//Bean으로 등록해준다 -> 해당 메서드 리턴되는 오브젝트 IOC로 등록
//password가 DB에 저장될 때, 그대로 저장되는것이 아닌 암호화되어서 저장된다.
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)throws Exception{
http.
csrf(AbstractHttpConfigurer::disable); //다른 도메인에서 API호출되는거 막지 않겠다. Rest Api -> 브라우저 통해 request 받아서 꺼도 됨.
// .cors(AbstractHttpConfigurer::disable); //이거 disable안하면 프런트에서 요청 보내면 response 안하고 에러 발생시킵니다
// .sessionManagement(s->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.
authorizeHttpRequests(au->
au.requestMatchers("/user").authenticated() //user로 시작하는 모든 요청은 인증이 되어야함
// Security3에서는 .accses.Role을 사용하지 않고 .hasRole을 사용, 기존에는 ROLE_을 붙여야 하지만 3에서는 자동적으로 ROLE_을 붙여준다
.requestMatchers("/manager").hasAnyRole("MANAGER","ADMIN") //manager로 시작하는 모든 요청은 ROLE_MANAGER, ROLE_ADMIN 권한이 있어야함
.requestMatchers("/admin").hasAnyRole("ADMIN") //admin으로 시작하는 모든 요청은 ROLE_ADMIN 권한이 있어야함
.anyRequest().permitAll() //그 외의 요청은 모두 허용
);
http.formLogin(f ->
f.loginPage("/loginForm")
.loginProcessingUrl("/login") // /login으로 호출오면 세큐리티가 낚아채서 로그인 진행
.defaultSuccessUrl("/") //loginForm으로 와서 로그인하면 /로 이동하는데, user로 와서 로그인하면 /user로 이동하게 설정
.permitAll())
.httpBasic(AbstractHttpConfigurer::disable);
http.oauth2Login(
oauth -> oauth.loginPage("/loginForm") //구글 로그인이 완료된 뒤의 후처리. 엑세스토큰 + 사용자프로필정보 받아옴. 로그인 여기서 해야함
.defaultSuccessUrl("/home") //로그인 성공하고 URL Redirect
.userInfoEndpoint() //사용자 정보에 대한 엔드포인트 구성
.userService(principalOauth2UserService) //구글 로그인이 완료된 뒤의 후처리. 엑세스토큰 + 사용자프로필정보 받아옴
);
return http.build();
}
}
oauthLogin부분에서 userInfoEndpoint().userService()
부분은 로그인 하고 user의 accessToken과 프로필을 가져오는 로그인 후처리를 해주는 부분으로 코드로 우리가 일일이 code로 accessToken 가져오고, 그 accessToken으로 유저 정보를 가져오는것이 아니라 우리가 우리가 위에서 Autowired로 선언해준 principalOauth2UserService를 넣어주면 spring security에서 제공하는 oauthClient dependency로 자동적으로 유저 정보를 가져와서 편리하게 해결할 수 있습니다.
@Service
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
//구글에서 받은 유저 데이타 후처리하는 함수
@Autowired
private UserRepo userRepo;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//userRequest에서는 구글에서 받은 유저 정보가 있다
System.out.println("getClientRegistration : " + userRequest.getClientRegistration()); //어떤 OAuth로 로그인했는지 확인 가능
//구글 로그인 버튼 클릭->구글 로그인 창->로그인 후 code리턴받고 이 code를 oauth client라이브러리가 받아서 access token 요청
//userRequest 정보 -> loadUser 함수 호출 -> 구글로부터 회원프로필 받아준다
OAuth2User oauth2User = super.loadUser(userRequest);
System.out.println("attributes : " + oauth2User.getAttributes());
//자동 회원가입
String provider = userRequest.getClientRegistration().getClientId(); //google
String providerId = oauth2User.getAttribute("sub"); //google의 primary key
String username = provider + "_" + providerId; //google_sub -> 중복될 일 없음
String email = oauth2User.getAttribute("email");
String role = "ROLE_USER";
User user= userRepo.findByUsername(username);
if(user == null){
user = User.builder()
.username(username)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepo.save(user);
}
//oauth login하면 user와 attributes Map을 가지고 authentication을 만들어준다
return new PrincipalDetails(user,oauth2User.getAttributes()); //PrincipalDetails가 Authentication에 들어간다
}
}
여기까지 진행되었다면 UX상의로 유저가 구글 로그인에 성공을 했고, 구글 - > oauthClient(우리의 springboot 서비스)로 인증코드는 return해준 상태입니다.
우리가 할 일: 인증코드를 바탕으로 accessToken을 얻고, accessToken으로 user의 프로필 정보를 가져오자!
loadUser
: OAuth2UserRequest 객체를 매개변수로 받습니다. 이 객체는 구글로부터 받은 사용자의 인증 정보를 포함하고 있고, loadUser 메서드는 이 인증 정보를 기반으로 구글에게 사용자 프로필 정보를 요청합니다.
super.loadUser()
: 우리가 extends한 DefaultOAuth2UserService class의 loadUser메서드를 호출합니다. 이 코드로 인해 우리의 서비스가 userRequest안에 있는 인증 정보를 가지고 유저의 프로필 정보를 가져오는 역할을 합니다.
Oauth Login을 하면 구글에서는 OAuth2User 타입으로 되돌려줘서 OAuth2User type으로 받아줘야 합니다.
oauth2User
: 이 객체 안에는 유저의 이름, 이메일, 프로필 url과 같은 정보들이 들어있습니다.
구글 로그인 성공하면 SpringSecurity에서 설정한 defaultSuccessUrl인 /home
으로 이동한것을 볼 수 있고, 404에러는 UI가 없는건데, 이 부분은 제가 따로 UI를 만들지 않아서 나온 에러이고, 서버 에러는 아니니 걱정 안하셔도 됩니다. 두번째 사진은 인텔리제이 log에 찍힌 것을 가져왔는데 유저 프로필 정보도 잘 받아온 것을 알 수 있습니다.
// Oauth login은 PrincipalDetails/UserDetails 캐스팅 받을 수 없다
@GetMapping("/test/oauth/login")
public String testOauthLogin(Authentication authentication){ //DI로 PrincipalDetails를 받고, PrincipalDetails에는 User가 들어있다
System.out.println("/test/oauth/login ===================");
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
System.out.println("authentication : " + oAuth2User.getAttributes()); //getAttributes -> user의 정보 Map<String,Object>로 받아온다
return "Oauth Session Check";
}
위 코드는 /loginForm
에서 Google Login을 하고 로그인 하고, 정보를 잘 받아왔는지 확인하려고 만든 Api입니다.
/loginForm
으로 구글 로그인을 한 후 /test/oauth/login
을 들어가봅니다.
Oauth를 이용해서 구글 로그인 잘 구현한걸 확인할 수 있습니다.
🤔 : 여기서 왜 Oauth2User 타입이 아닌 Authentication으로 받을까요??
Spring Security에서는 인증된 사용자 정보를 Security Session(Security Context)에 저장되는데, Security Session안에는 Authentication 객체밖에 들어가지 못합니다. 저장된 객체는 사용자의 HTTPSession에 저장되어 세션이 지속될 때까지 저장됩니다.
Session??
웹 서비스 - 서버가 상호작용을 일정시간동안 추적하는 방법.
일반적인 웹 HTTP Session은 일반적으로 Stateless(Request & Response) 한번 완료되면 종료됩니다. 하지만 많은 서비스는 사용자가 어떤 활동을 하는지 관측해하고, 페이지가 이동하더라도 유저의 정보를 사용해야하기 때문에 정보를 유지해야 하기 때문에 Session을 이용하는 것입니다.
이제 oauth로 구글 로그인 받아오는 것은 완료되었습니다!
할 일 -> 받아온 정보 자동적으로 우리 DB에 넣어서 회원가입하기
🤔 : 왜??
😎 : 유저가 로그인하고 또 우리 서비스에서 회원가입 하는거는 UX상 비효율적이여서 자동 회원가입 해줍시다~
DB에 있는 User의 구조는 Oauth2User와 다르기 때문에 Oauth2User 객체를 DB에 그대로 넣을 수 없습니다. 아래 코드가 DB에 넣을 수 있는 타입입니다.
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String username;
private String password;
private String email;
private String role;
private String provider; //google
private String providerId; //google의 id 이 두개로 oauth2로 로그인한 유저인지 판단
private Timestamp loginDate;
@CreationTimestamp
private Timestamp createDate;
}
PrincipalOauth2UserService.java
에서
//자동 회원가입
String provider = userRequest.getClientRegistration().getClientId(); //google
String providerId = oauth2User.getAttribute("sub"); //google의 primary key
String username = provider + "_" + providerId; //google_sub -> 중복될 일 없음
String email = oauth2User.getAttribute("email");
String role = "ROLE_USER";
User user= userRepo.findByUsername(username);
if(user == null){
user = User.builder()
.username(username)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepo.save(user);
}
//oauth login하면 user와 attributes Map을 가지고 authentication을 만들어준다
return new PrincipalDetails(user,oauth2User.getAttributes()); //PrincipalDetails가 Authentication에 들어간다
}
provider,providerId,username,email,role을 Oauth2User
에 있는 값들에서 전부 String으로 추출하고, builder
를 이용해서 User객체로 만들고, save
JPA 쿼리문을 이용해 DB에 넣어줍니다.
/loginForm
으로 로그인이 완료되면, session에 Authentication객체가 들어간다고 했는데, Authentication객체 안에는 User정보 넣어주는 코드도 구현해줘야 합니다.
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
//loadByUser실행될 때 PrincipalDetails가 Authentication에 들어간다
private User user; //composition
private Map<String,Object> attributes; //OAuth인증 시 사용되는 사용자의 추가 정보를 저장
//생성자에서 user 받아서 PrincipalDetails에 넣어준다
public PrincipalDetails(User user){
this.user = user;
}
//Oauth2로 로그인을 하면 사용하는 생성
//attributes로 user를 만들어준다
public PrincipalDetails(User user,Map<String,Object> attributes)
{
this.user = user;
this.attributes = attributes;
}
//아래는 인터페이스 구현하는데 필요한 추가 메서드들
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
//해당 User의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority(){
@Override
public String getAuthority() {
return user.getRole();
}
});
return collection;
}
@Override
public String getUsername() {
return user.getUsername();
}
}
UserDetails, OAuth2User를 implements하는 이유
-> Authentication 객체 안에는 UserDetails, OAuth2User 이 두개의 타입만 들어갈 수 있습니다. 따라서 Spring Security가 일반로그인과 소셜 로그인 모두 처리할 수 있게 설정해줘야 합니다.
OAuth와 같은 소셜로그인이 아니라 서비스 내 로그인과 회원가입이라면 ?UserDetails
OAuth를 이용한 소셜 로그인 서비스라면? OAuth2User
이 두개의 타입만 Authentication안에 User객체를 넣어줍니다. HTTP 요청이 들어오면 Security는 Authentication을 찾는데, Authentication에 들어오는 값이 위 두개의 type이 아니라면 잘못된 type이라고 에러가 발생합니다.
여기까지 하면 OAuth를 이용한 스프링부트 소셜 로그인 구현 끝입니다! 🎉