🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [62]일차
🚀62일차에는 Github와 연동하여 OAuth2 소셜로그인을 실습해보았다.
학습 목표 : Github의 소셜로그인 ID를 통해 연동하여 DB에 값이 잘 넘어가야함
학습 과정 : 회고를 통해 작성
OAuth
- oauth는 인증까지만 도와주고 나머지는 시큐리티가 담당해야함
OAuth 사용 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Entity - User
- 기존 User 코드에 추가적으로 OAuth의 소셜과 관련된 컬럼을 추가
- OAuth 인증을 사용한 경우에도 socialId와 provider를 추가로 저장해서
로그인 시 기존 사용자와 매칭할 수 있도록 설계
// ...
@Column(name = "registration_date", nullable = false, updatable = false)
private LocalDateTime registrationDate = LocalDateTime.now();
// 컬럼 추가 - OAuth의 소셜 아이디를 가져오는 부분
@Column(name = "social_id")
private String socialId;
private String provider; // 어느 소셜에서 제공중인지를 가져옴
- registrationDate LocalDateTime ➡️가입한 날짜 (기본값: 현재 시간)
- socialId String socialId
➡️ ex. google-oauth2-123456789
➡️기존 회원가입(username, password)과 OAuth(socialId, provider)를 모두 지원 - provider String provider ➡️ ex. google", "github"
- 동작 흐름 :
OAuth 로그인한 사용자가 기존 User 테이블에 없다면, 새롭게 회원가입을 진행
SocialUser - 소셜로그인 유저를 관리할 엔티티 생성
- OAuth 인증을 통해 로그인한 사용자의 정보를 저장하는 엔티티
- 소셜 로그인 사용자의 계정 정보를 따로 관리
(=일반적인 User 테이블과 별개로 소셜 로그인 전용 계정을 저장 가능)
@Entity
@Data
public class SocialUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String socialId;
private String provider;
private String username;
private String email;
private String avatarUrl;
}
- @Data
➡️Getter & Setter, toString(), equals() & hashCode(), 기본 생성자 등을 자동으로 생성해줌
- private String provider
➡️사용자가 로그인한 소셜 제공자 (ex. "google", "facebook", "github") - private String avatarUrl
➡️사용자의 프로필 이미지 URL(ex. 구글 프로필 이미지)
- socialId를 사용해 중복 로그인 문제를 방지가능
Repository 설계
@Repository
public interface UserRepository extends JpaRepository<User,Long> {
User findByUsername(String username);
Optional<User> findByProviderAndSocialId(String provider, String socialId);
}
- findByUsername(String username) ➡️ 일반 로그인 시, username으로 사용자를 찾음
- findByProviderAndSocialId(String provider, String socialId)
➡️ OAuth 로그인 시, 소셜 제공자(provider)와 socialId로 사용자를 찾음
(= OAuth를 통해 가입한 기존 사용자를 쉽게 조회)
만약 기존 사용자가 없다면, 새로운 계정을 자동으로 생성 가능 - OAuth는 username/password 방식이 아니므로 username이 아니라,
각 플랫폼에서 제공하는 고유한 사용자 ID (socialId)로 인증해야 함
ex. 동일한 이메일(example@gmail.com)을 사용하더라도
Google과 Facebook에서는 서로 다른 socialId를 가질 수 있음
Service 설계
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public User saveUser(SocialUserRequestDto requestDto){
User user = new User();
user.setUsername(requestDto.getUsername());
user.setName(requestDto.getName());
user.setEmail(requestDto.getEmail());
user.setSocialId(requestDto.getSocialId());
user.setProvider(requestDto.getProvider());
user.setPassword(passwordEncoder.encode("")); // 소셜 로그인으로 진행하는 사용자는 비밀번호를 비워둠
return userRepository.save(user);
}
@Transactional(readOnly = true)
public User findByUsername(String username){
return userRepository.findByUsername(username);
}
@Transactional(readOnly = true)
public Optional<User> findByProviderAndSocialId(String provider, String socialId) {
return userRepository.findByProviderAndSocialId(provider, socialId);
}
}
- saveUser(SocialUserRequestDto requestDto){…}
➡️OAuth 로그인 사용자를 저장하는 메서드
동작 흐름 :
- OAuth 인증 후, 기존 사용자 여부를 확인하여 새로운 사용자인 경우 회원가입 처리
- 비밀번호는 직접 입력하지 않고 password 필드를 공란("")으로 설정 후 해싱하여 저장
- username, email 등의 정보는 OAuth 제공자로부터 받아 저장 - ❓비밀번호를 공란으로 설정하는 이유
➡️일반 사용자는 비밀번호를 입력하고 가입하지만,
소셜 로그인 사용자는 Google, Facebook 등 OAuth 제공자의 인증을 거쳐 로그인하므로 비밀번호가 필요 없음
✅로그인 시 OAuth 계정은 비밀번호 검증을 건너뛰도록 처리 - findByUsername(String username) { … }
➡️일반적인 username/password 로그인 방식에서 사용
➡️OAuth 사용자는 username을 기반으로 로그인하지 않음
정리하자면
최초의 사용자에 대해서는 추가적인 회원가입 정보를 위하여 회원가입으로 이동하고
그 이외로 로그인하게 될때는 이미 저장되어있을 수 있으므로 Dto로 가져와서 꺼내온 후 나머지 값만 입력받도록 함
소셜로그인 흐름
1. 사용자가 OAuth 로그인 버튼 클릭
- Google, Facebook, GitHub 등의 소셜 로그인 버튼 클릭
- OAuth 제공자의 인증 페이지로 리다이렉트
2. OAuth 인증 후 콜백 (Callback)
- 사용자가 OAuth 로그인에 성공하면, OAuth 제공자는 accessToken과 함께 사용자 정보를 반환
(=provider, socialId가 포함됨)
3. OAuth 로그인 사용자 정보 확인 (findByProviderAndSocialId)
- OAuth 로그인한 사용자가 DB에 존재하는지 확인
- 존재하면 로그인 처리, 존재하지 않으면 saveUser()를 호출하여 새롭게 가입
SocialUserService
@Service
@RequiredArgsConstructor
public class SocialUserService {
private final SocialUserRepository socialUserRepository;
// 소셜에서 보내준 정보를 저장하기 위한 메소드
@Transactional
public SocialUser saveOrUpdateUser(String socialId, String provider,
String username, String email,
String avatarUrl){
Optional<SocialUser> existUser = socialUserRepository.findBySocialIdAndProvider(socialId, provider);
SocialUser socialUser;
if(existUser.isPresent()){
// 1. 이미 소셜정보를 가진 사용자라면
socialUser = existUser.get();
socialUser.setUsername(username);
socialUser.setEmail(email);
socialUser.setAvatarUrl(avatarUrl);
}else{
// 2. 처음으로 방문한 사용자라면
socialUser = new SocialUser();
socialUser.setSocialId(socialId);
socialUser.setUsername(username);
socialUser.setEmail(email);
socialUser.setAvatarUrl(avatarUrl);
socialUser.setProvider(provider);
}
return socialUserRepository.save(socialUser);
}
}
- return socialUserRepository.save(socialUser);
변경된 사용자 정보를 저장
(=새로운 사용자이든, 기존 사용자 정보 업데이트든 결과적으로 DB에 저장)
최초 가입이면 INSERT, 기존 회원이면 UPDATE로 동작
(=save() 메서드는 INSERT 또는 UPDATE를 수행)
UPDATE는 예를 들어 ex. 깃허브 이메일이 변경된 경우 수행
SocialLoginInfoService
- OAuth 로그인 시 사용자의 소셜 로그인 정보를 저장 및 조회
@Service
@RequiredArgsConstructor
public class SocialLoginInfoService {
private final SocialLoginInfoRepository socialLoginInfoRepository;
@Transactional(readOnly = true)
public SocialLoginInfo saveSocialLoginInfo(String provider, String socialId){
SocialLoginInfo socialLoginInfo = new SocialLoginInfo();
socialLoginInfo.setProvider(provider);
socialLoginInfo.setSocialId(socialId);
return socialLoginInfoRepository.save(socialLoginInfo);
}
@Transactional(readOnly = true)
public Optional<SocialLoginInfo> findByProviderAndUuidAndSocialId(String provider, String uuid, String socialId){
return socialLoginInfoRepository.findByProviderAndUuidAndSocialId(provider, uuid, socialId);
}
}
- SocialUserService와는 달리 uuid 필드를 추가하여 식별 및 보안 관리가 가능하도록 함
❓uuid (고유 식별자)를 추가한 이유
➡️socialId만으로는 동일한 사용자가 여러 소셜 계정을 연결할 때 구별하기 어려울 수 있으므로 uuid를 추가하여
특정 애플리케이션 내에서 사용자를 정확하게 식별 가능
(=여러 개의 OAuth 계정을 사용할 가능성이 있는 경우를 대비해 uuid를 추가한 것)
SecurityConfig 설계
- OAuth2 소셜 로그인을 포함한 Spring Security 설정을 담당
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final SocialUserService socialUserService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/loginform","/userregform", "/").permitAll()
.requestMatchers("/oauth2/**", "/login/oauth2/code/github", "/registerSocialUser", "/saveSocialUser").permitAll()// 기본지정해주어야하는 약속들, 이 형태는 정해진 형태 (/login/oauth2/code/github)
.anyRequest().authenticated()
)
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.cors(cors -> cors.configurationSource(configurationSource()))
.httpBasic(httpBasic -> httpBasic.disable())
.oauth2Login(oauth2 -> oauth2
.loginPage("/loginform")
.failureUrl("/loginFailure")
.userInfoEndpoint(userInfo -> userInfo
.userService(this.oauth2UserService())
)
//.successHandler()
);
return http.build();
}
//... 이 외 설정
}
- @EnableWebSecurity ➡️ Spring Security 활성화
➡️기본 보안 설정을 직접 커스터마이징하기 때문에 @EnableWebSecurity가 필수
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(){
DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
return OAuth2UserRequest -> {
OAuth2User oAuth2User = delegate.loadUser(OAuth2UserRequest);
// 토큰도 필요 시 얻어올 수 있음
// String token = OAuth2UserRequest.getAccessToken().getTokenValue();
String provider = OAuth2UserRequest.getClientRegistration().getRegistrationId();
String socialId = (String) oAuth2User.getAttributes().get("id");
String username = (String) oAuth2User.getAttributes().get("login");
String email = (String) oAuth2User.getAttributes().get("email");
String avatarUrl = (String) oAuth2User.getAttributes().get("avatar_url");
socialUserService.saveOrUpdateUser(socialId, provider, username, email, avatarUrl);
return oAuth2User;
};
}
//... 이 외 설정
- DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
➡️기본 OAuth2 사용자 서비스 객체 생성
➡️OAuth 제공자로부터 사용자 정보를 가져올때 사용 - getAttributes() : 사용자 정보 추출
SecurityConfig - successHandler() 메소드 추가
- 소셜 로그인 성공 후 해야할 일을 작성
.userInfoEndpoint(userInfo -> userInfo
.userService(this.oauth2UserService())
)
// 소셜 인증 성공 후 수행할 일
.successHandler(customOAuth2AuthenticationSuccessHandler)
- OAuth 로그인 성공 시 기본 동작만 수행하던 것을
successHandler를 추가하여 OAuth 로그인 성공 후 커스텀 로직을 수행 가능
ex. 로그인 후 JWT 발급, 추가 정보 저장, 리다이렉트 등을 수행할 수 있음
CustomOAuth2AuthenticationSuccessHandler
@Component
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final UserService userService; // 유저 정보를 저장하기 위함
private final SocialLoginInfoService socialLoginInfoService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String requestURI = request.getRequestURI();
String provider = extractProviderFromUri(requestURI);
// provider가 없는 경로로 요청되었으면 문제 발생
if(provider == null){
response.sendRedirect("/");
return;
}
// 문제없었으면
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
DefaultOAuth2User defaultOAuth2User = (DefaultOAuth2User) auth.getPrincipal();
String socialId = defaultOAuth2User.getAttributes().get("id").toString();
String name = defaultOAuth2User.getAttributes().get("name").toString();
Optional<User> userOptional = userService.findByProviderAndSocialId(provider, socialId);
if(userOptional.isPresent()){
User user = userOptional.get();
// CustomUserDetails 생성
CustomUserDetails customUserDetails = new CustomUserDetails(user.getUsername(),
user.getPassword(),
user.getName(),
user.getRoles()
.stream()
.map(Role::getName)
.collect(Collectors.toList())
);
Authentication newAuth = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(newAuth);
response.sendRedirect("/welcome");
}else{
SocialLoginInfo socialLoginInfo = socialLoginInfoService.saveSocialLoginInfo(provider, socialId);
response.sendRedirect("/registerSocialUser?/provider="+provider+"&socialId="
+socialId+"&name="+name+"&uuid="+socialLoginInfo.getUuid());
}
}
private String extractProviderFromUri(String uri) { //... 처리 부분 }
}
- requestURI ➡️사용자가 요청한 URL (ex. /login/oauth2/code/github)
요청 정보로부터 provider를 얻어옴
ex. redirect-uri : "{baseUrl}/login/oauth2/code/{registrationId}" : registrationId 에 github, google등이 들어올 것 - if(userOptional.isPresent()){ ... }
소셜로 회원가입이 된 상태일때 (=로그인을 성공한 적이 있는 유저)
이 사용자가 서비스를 이미 사용한 정보가 있으면 = User에 정보가 담겨있을 것 - user.getRoles()
➡️Roles는 Set<Role> roles 처럼 들어가있을 것
따라서 stream()으로 꺼냄 - extractProviderFromUri()
➡️ex. github, google 등의 OAuth 제공자 이름(provider)을 추출
if(provider == null){
response.sendRedirect("/");
return;
}
- provider가 없으면 홈으로 리다이렉트
- provider가 정상적으로 추출되지 않으면 로그인 요청이 잘못된 것이므로 홈으로 이동
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
➡️ 현재 인증된 사용자의 정보를 SecurityContextHolder에서 가져옴 - response.sendRedirect("/welcome"); ➡️로그인 성공 후 /welcome 페이지로 리다이렉트
- response.sendRedirect("/registerSocialUser?/provider="+provider+"&socialId="
+socialId+"&name="+name+"&uuid="+socialLoginInfo.getUuid());
➡️소셜로 회원가입이 되지 않은 상태일때 "회원가입 페이지"로 리다이렉트
➡️사용자가 소셜로그인으로 앱에 처음들어왔을때 사용자의 정보를 앱의 Service에도 남겨두고 싶을때
➡️소셜 로그인 정보를 저장함
(/?provider 형식으로 provider를 controller에 넘김, &로 넘기고 싶은 나머지 값들을 한 줄로 연결시킴)
OAuth 로그인 (github)
- 사용자가 https://myapp.com/oauth2/authorization/github 클릭
- GitHub OAuth 인증 페이지로 이동
- 사용자가 인증을 완료하면 GitHub이 https://myapp.com/login/oauth2/code/github?code=xyz로 리다이렉트
- OAuth2 로그인 성공
- SecurityConfig의 .oauth2Login()에서 CustomOAuth2AuthenticationSuccessHandler 호출
- onAuthenticationSuccess()에서 provider, socialId 등의 정보를 가져옴
- 기존 사용자면 로그인 처리 후 /welcome으로 이동
- 신규 사용자면 회원가입 페이지(/registerSocialUser)로 이동
▶️실습 - application github 설정 추가
# security:
# user:
# name: Jun
# password: 1234
security:
oauth2:
client:
registration:
github:
client-id: 클라이언트 ID
client-secret: 클라이언트 비밀키
scope:
- email
- profile
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: GitHub
provider:
github:
authorization-uri: <https://github.com/login/oauth/authorize>
token-uri: <https://github.com/login/oauth/access_token>
user-info-uri: <https://api.github.com/user>
user-name-attribute: id
loginform.html
<form action="/login" method="post">
<!--로그인 폼들-->
<a href="/oauth2/authorization/github"></a>
</form>
⚠️오류 발생 해결
순환 참조 관계
- SecurityConfig → CustomOAuth2AuthenticationSuccessHandler 필요
- CustomOAuth2AuthenticationSuccessHandler → UserService 필요
- UserService → SecurityConfig 필요 (직간접적으로 의존)
서로가 서로를 의존하는 구조로 되어 있어 Spring이 Bean을 생성할 수 없다는 오류
기존 구조
- SecurityConfig가 CustomOAuth2AuthenticationSuccessHandler를 주입 받음
- CustomOAuth2AuthenticationSuccessHandler가 UserService를 주입 받음
- UserService가 다시 SecurityConfig에 의존하거나, 그 안의 socialUserService를 사용
✅해결방법 :
UserService에 PasswordEncoder를 사용하지 않고,
Controller에서 PasswordEncoder를 사용 후 UserService에 넘기는 방식으로 구현해야할 것
UserController
private final UserService userService;
private final SocialLoginInfoService socialLoginInfoService;
private final PasswordEncoder passwordEncoder;
// 로그인 폼
@GetMapping("/loginform")
public String loginform(){
return "oauth/users/loginform";
}
// UserService로 Password를 인코딩해서 넘기도록 함 (순환참조 문제 해결)
@GetMapping("/registerSocialUser")
public String registerSocialUser(){
return "";
}
- password를 인코딩해서 넘기도록 함
- oauth/users/loginform 처럼 loginform이 겹칠 수 있으므로 경로명을 적어줌
UserService
private final UserRepository userRepository;
// private final PasswordEncoder passwordEncoder; // 순환참조 문제 발생
@Transactional
public User saveUser(SocialUserRequestDto requestDto, PasswordEncoder passwordEncoder){
User user = new User();
user.setUsername(requestDto.getUsername());
user.setName(requestDto.getName());
user.setEmail(requestDto.getEmail());
user.setSocialId(requestDto.getSocialId());
user.setProvider(requestDto.getProvider());
user.setPassword(passwordEncoder.encode("")); // 소셜 로그인으로 진행하는 사용자는 비밀번호를 비워둠
return userRepository.save(user);
}
- PasswordEncoder 주석처리 후 saveUser()메소드에서 PasswordEncoder를 인자로 받게되면 해결 가능
- 하이퍼링크가 localhost:8080/oauth2/authrorization/github를 가리키고 있음
- 이처럼 아직 provider, social_id는 존재하지 않는 것을 확인할 수 있다.
Controller 수정
- URL에 담긴 값으로 RequestParam을 활용하여 회원가입 페이지로 리다이렉트 되도록 구현
@GetMapping("/registerSocialUser")
public String registerSocialUser(@RequestParam("provider") String provider,
@RequestParam("socialId") String socialId,
@RequestParam("name") String name,
@RequestParam("uuid") String uuid,
Model model){
// Model에 담아주어야함 - 회원가입 페이지에 넘어갈때 이 값들을 사용할 수 있도록 하는 것
model.addAttribute("provider", provider);
model.addAttribute("socialId", socialId);
model.addAttribute("name", name);
model.addAttribute("uuid", uuid);
return "oauth/users/registerSocialUser"; // 소셜로그인 회원가입 페이지로 보냄
}
- @RequestParam
?provider="+provider+"&socialId="+socialId+"&name="+name+... 와 같은 구조로 들어올 것
따라서 @RequestParam을 이용하여 그 값들을 받아옴
OAuth 로그인 후 리다이렉트될 때 쿼리 파라미터(?provider=google&socialId=1234 등)를 사용하여 정보를 전달 - OAuth 로그인 후 기존 회원이 아니라면 추가 정보 입력을 위해 회원가입 페이지로 이동하도록 처리
UserController - saveSocialUser 메소드 추가
@PostMapping("/saveSocialUser")
public String saveSocialUser(@ModelAttribute SocialUserRequestDto requestDto){
Optional<SocialLoginInfo> socialLoginInfoOptional = socialLoginInfoService.findByProviderAndUuidAndSocialId(
requestDto.getProvider(), requestDto.getUuid(), requestDto.getSocialId());
if(socialLoginInfoOptional.isPresent()){
SocialLoginInfo socialLoginInfo = socialLoginInfoOptional.get();
LocalDateTime now = LocalDateTime.now(); // 현재 시간 구하기
Duration duration = Duration.between(socialLoginInfo.getCreateAt(), now); // 소셜로그인 정보가 생긴 시간과 현재 시간을 비교
if(duration.toMinutes() > 20) {// 20분을 초과했으면
return "redirect:/error"; // 에러페이지로 리다이렉트할 것
}
userService.saveUser(requestDto, passwordEncoder);
return"redirect:/info";
}
else{ // 정보가 없다면
return "redirect:/error";
}
}
- 사용자가 OAuth 로그인 후 회원가입을 진행할 때, 보안상 20분 이내에 가입해야 하는 제한을 두는 기능 포함
- 만약 20분이 초과하면 가입할 수 없도록 하고, 사용자를 /error 페이지로 리다이렉트
- 회원가입이 성공하면 /info 페이지로 이동하여 사용자의 정보를 보여주도록 설계
🚀실습 - Login With Github 테스트
- 소셜로그인을 호출할 수 있다
🚀회고 결과 :
소셜로그인을 사용만 해보고 직접 구현하는 것은 처음이었다.
소셜로그인의 동작 흐름이 어떤지를 회고를 통해서 배울 수 있었고,
빠르게 지나갔던 내용들을 다시 메소드 별로 학습하면서 메소드 간 상호작용이 어떻게 수행되고 있는지를 파악할 수 있었다.
오류도 많이 일어났었는데 오류 로그를 분석해보면서 검색을 통해 해결할 수 있었다.
향후 계획 :
- 소셜 로그인 로그아웃 후 재인증 테스트
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_63일차_"Swagger" (0) | 2025.03.11 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_61일차_"Spring Security 예외" (0) | 2025.03.07 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_60일차_"Jwt Authentication" (0) | 2025.03.06 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_58일차_"Spring Security와 DB연결" (0) | 2025.03.04 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_57일차_"Spring Security - 권한, 쿠키" (0) | 2025.02.28 |