0. 소셜 로그인 사례
많은 웹 서비스에서 구글, 카카오, 네이버, 애플 등 다양한 로그인 방식을 채택하고 있다.
그 중 구글 로그인 방식을 구현하도록 하겠다.
1. Google OAuth 서비스 등록
1-1. Google OAuth 서비스를 등록하기 위해 "구글 클라우드 콘솔" 로 접속한다.
https://console.cloud.google.com/apis
1-2. Google OAuth 서비스 등록하기
"Google Cloud" 로고 옆에 프로젝트 선택 드롭다운을 클릭한다.
"새 프로젝트" 를 클릭한다.
"프로젝트 이름" 을 입력하고 "만들기" 버튼을 누른다.
사이드 메뉴에서 "OAuth 동의 화면" 을 누르고, "External" 라디오 버튼을 선택한 후 "만들기" 버튼을 누른다.
앱 정보의 "앱 이름" , "사용자 지원 이메일" 을 입력한다.
아래로 내려서 "개발자 연락처 정보"를 입력하고, "저장 후 계속" 버튼을 클릭한다.
"범위 추가 또는 삭제" 버튼을 누른다.
'email', 'profile', 'openid' 를 선택한 범위로 추가 후 "업데이트" 버튼을 누른다.
"Test users" 는 "저장 후 계속" 버튼을 눌러 넘어간다.
측면 메뉴의 "사용자 인증 정보" 를 눌러 "사용자 인증 정보 만들기" -> "OAuth 클라이언트 ID" 를 누른다.
"Create OAuth client ID" 의 "애플리케이션 유형" 으로 "웹 애플리케이션" 을 선택하고, "이름" 을 입력한다.
"승인된 리디렉션 URL" 에 "http://localhost:8080/login/oauth2/code/google" 을 추가 후 "저장" 버튼을 누른다.
※ 개인이 개발중인 서비스의 URL 을 "localhost:8080" 자리에 넣으면 된다.
"OAuth 클라이언트 생성됨" 페이지를 통해 성공함을 확인할 수 있다.
2. build.gradle - OAuth2 의존성 추가하기
//OAuto
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
3. "application-oauth.yml" 추가하기 (Google OAuth 정보)
spring:
security:
oauth2:
client:
registration:
google:
client-id: "your-client-id"
client-secret: "your-client-secret"
scope:
- profile
- email
※ "application-oauth.yml" 파일은 ".gitignore" 에 추가하여 깃허브에 push 하지 않는다.
"application.yml" 에 "application.oauth.yml" 을 추가한다.
4. "Member.class" , "BaseEntity.class" , "UserRole.enum"
[Member.class]
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Member extends BaseEntity{
/**
* member_id(PK)
* username
* password
* email
* phone
* userRole
*/
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
private String password;
private String email;
private String phone;
@Enumerated(EnumType.STRING)
private UserRole userRole = UserRole.USER;
@OneToMany(mappedBy = "member")
private List<Task> tasks = new ArrayList<>();
... 중략 ...
/*소셜 로그인시 이미 등록된 회원의 수정날짜만 업데이트 하기 위함*/
public Member updateUpdateAt() {
this.onPreUpdate();
return this;
}
}
소셜 로그인 시 이미 등록된 회원 - 수정일자만 업로드
소셜 로그인 시 기존에 존재하지 않은 회원 - 회원 저장
"updateUpdateAt()" 메서드는 소셜 로그인 시 이미 등록된 회원에 대해 수정일자만 업로드 하는 메서드 이다.
[BaseEntity.class]
@MappedSuperclass
@Getter
public class BaseEntity {
@Column(name = "created_at")
@CreatedDate
private String createdAt;
@Column(name = "updated_at")
@LastModifiedDate
private String updatedAt;
@PrePersist
public void onPrePersist() {
this.createdAt = LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy.MM.dd. HH:mm")
);
this.updatedAt = this.createdAt;
}
@PreUpdate
public void onPreUpdate() {
this.updatedAt = LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy.MM.dd. HH:mm")
);
}
}
[UserRole.enum]
/**
* SUPER_ADMIN : 최고 관리자
* ADMIN : 관리자
* USER : 회원
* SOCIAL : 소셜 로그인 회원
*/
public enum UserRole {
SUPER_ADMIN,
ADMIN,
USER,
SOCIAL //OAuth
}
5. "WebSecurityConfig.class" , "CustomOAuth2UserService.class" , "OAuthAttributes.class"
[WebSecurityConfig.class]
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
@Autowired private CustomUserDetailsService customUserDetailsService;
/*OAuth*/
@Autowired private CustomOAuth2UserService customOAuth2UserService;
// PasswordEncoder Bean 등록 - password 암호화 (방식 - BCryptPasswordEncoder)
@Bean
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// WebSecurityCustomizer Bean 등록 - 정적 resources 접근을 위함
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// 정적 리소스가 위치한 파일의 보안 처리를 무시 (누구든 접근 가능)
return (web -> web.ignoring()
.requestMatchers("/img/**", "/css/styles.css")
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()));
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.requestMatchers("/", "/members/login", "/members/join").permitAll()
.anyRequest().authenticated()
)
.formLogin((form) ->
form
.usernameParameter("username")
.passwordParameter("password")
.loginPage("/members/login")
.loginProcessingUrl("/login")
.defaultSuccessUrl("/", true)
.permitAll()
)
.userDetailsService(customUserDetailsService)
.logout(logout ->
logout
.logoutUrl("/logout") //로그아웃 처리 URL
.logoutSuccessUrl("/") //로그아웃 성공 후 리다이렉트 할 URL
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
)
.csrf(csrf ->
csrf
.ignoringRequestMatchers("/api/**") // /api/** 경로에 대한 CSRF 보호를 비활성화
)
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)))
;
return http.build();
}
}
"filterChain" 에서 "OAuth2" 관련 설정을 추가한다.
[CustomOAuth2UserService.class]
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
private final HttpSession session;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// OAuth2 서비스 id 구분 코드 (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드 값 (PK) (구글의 기본 코드는 "sub")
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService - //인증자가 누구인지 구분 메서드
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
Member member = saveOrUpdate(attributes);
// OAuth2User 객체로부터 UserDetails 객체 생성
UserDetails userDetails = User.builder()
.username(member.getUsername()) // 사용자 이름 또는 ID
.password("") // 비밀번호 필드
.roles(String.valueOf(member.getUserRole())) // 사용자 역할, "ROLE_" 접두사 없이 설정
.build();
// Spring Security가 인증 수행
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return new DefaultOAuth2User(
userDetails.getAuthorities(),
attributes.getAttributes(),
"email");
}
/*소셜 로그인시,
* 기존 회원 - 수정일자(updateAt)만 업데이트
* 새로운 회원 - 회원(member) 저장*/
private Member saveOrUpdate(OAuthAttributes attributes) {
Member member = memberRepository.findByEmail(attributes.getEmail())
.map(Member::updateUpdateAt)
.orElse(attributes.toEntity());
return memberRepository.save(member);
}
}
[OAuthAttributes.class]
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String username;
private String email;
private UserRole role;
//인증자가 누구인지 구분 메서드
public static OAuthAttributes of(String registrationId,
String usernameAttributeName,
Map<String, Object> attributes) {
//1. Google
if (registrationId.equals("google")) {
return ofGoogle(usernameAttributeName, attributes);
}
return null;
}
private static OAuthAttributes ofGoogle(String usernameAttributeName,
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.username((String) attributes.get("email"))
.email((String) attributes.get("email"))
.attributes(attributes)
.nameAttributeKey(usernameAttributeName)
.build();
}
public Member toEntity() {
return Member.builder()
.username(email)
.email(email)
.userRole(UserRole.SOCIAL)
.build();
}
}
6. Thymeleaf
[login.html]
<!DOCTYPE html>
<html lang="kr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<!--Login Form-->
<div class="login-box">
<h2>Login</h2>
<form th:action="@{/login}" th:object="${loginForm}" method="post">
<div class="user-box">
<input id="login-username" type="text" name="username" th:field="*{username}" required="">
<label for="login-username">Username</label>
</div>
<div class="user-box">
<input id="login-password" type="password" name="password" th:field="*{password}" required="">
<label for="login-password">Password</label>
</div>
<div>
<button type="submit">submit</button>
</div>
<div th:if="${param.error}" style="color: red;">
<p th:text="${param.message}">Error message here</p>
</div>
<!--간편 로그인-->
<div class="social-login-container">
<!--구글 로그인-->
<a href="/oauth2/authorization/google" role="button" class="social-login-btn">
<img src="/img/google-logo.png" alt="google-login" class="login-img"/>
구글로 로그인
</a>
<!--네이버 로그인-->
<a href="" role="button" class="social-login-btn" style="background: #1EC800;">
<img src="/img/naver-logo.png" alt="naver-login" class="login-img"/>
네이버로 로그인
</a>
<!--카카오 로그인-->
<a href="" role="button" class="social-login-btn" style="background: #FEE500;">
<img src="/img/kakao-logo.png" alt="kakao-login" class="login-img"/>
카카오로 로그인
</a>
</div>
</form>
</div>
</body>
</html>
<!--구글 로그인-->
<a href="/oauth2/authorization/google" role="button" class="social-login-btn">
<img src="/img/google-logo.png" alt="google-login" class="login-img"/>
구글로 로그인
</a>
"/oauth2/authorization/google" 로 설정한다.
7. 완성 화면
+) "CustomOAuth2UserService.class" , "OAuthAttributes.class" 분석
[ CustomOAuth2UserService.class]
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2UserService<OAuth2UserRequest, OAuth2User>
인터페이스 - 'OAuth2UserService' 는 'OAuth2UserRequest' 를 받아 'OAuth2User' 를 반환하는 서비스 인터페이스
역할 - OAuth 2.0 제공자로부터 사용자 정보를 가져오는 역할
DefaultOAuth2UserService
구현체 - ' DefaultOAuth2UserService' 는 'OAuth2UserService' 인터페이스의 기본 구현체
기능 - OAuth 2.0 제공자의 사용자 정보 엔드포인트를 호출하여 사용자 정보를 가져오고, 이를 'OAuth2User' 객체로 변환
OAuth2User oAuth2User = delegate.loadUser(userRequest);
loadUser(userRequest)
: 'DefaultOAuth2UserService' 의 메서드로, 'OAuth2UserRequest' 를 받아서 사용자 정보를 가져오고 'OAuth2User' 객체로 반환합니다.
userRequest
: OAuth 2.0 로그인 요청 정보를 포함하는 객체입니다. 여기에는 엑세스 토큰과 클라이언트 등록 정보가 포함됩니다.
oAuth2User
: OAuth 2.0 제공자로부터 가져온 사용자 정보를 담고 있는 객체입니다. 이 객체에는 사용자 속성(attribute)들이 포함됩니다.
[OAuthAttributes.class]
+) [오류 해결] 현재 로그인된 회원 표시 - (CustomOAuth2UserService.class)
변경전 - CustomOAuth2UserService.class
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
private final HttpSession session;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// OAuth2 서비스 id 구분 코드 (구글, 카카오, 네이버)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드 값 (PK) (구글의 기본 코드는 "sub")
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService - //인증자가 누구인지 구분 메서드
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
Member member = saveOrUpdate(attributes);
// OAuth2User 객체로부터 UserDetails 객체 생성
UserDetails userDetails = User.builder()
.username(member.getUsername()) // 사용자 이름 또는 ID
.password("") // 비밀번호 필드
.roles(String.valueOf(member.getUserRole())) // 사용자 역할, "ROLE_" 접두사 없이 설정
.build();
// Spring Security가 인증 수행
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
/**
* 세번째 파라미터 - attributes.getNameAttributeKey()
*/
return new DefaultOAuth2User(
userDetails.getAuthorities(),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
/*소셜 로그인시,
* 기존 회원 - 수정일자(updateAt)만 업데이트
* 새로운 회원 - 회원(member) 저장*/
private Member saveOrUpdate(OAuthAttributes attributes) {
Member member = memberRepository.findByEmail(attributes.getEmail())
.map(Member::updateUpdateAt)
.orElse(attributes.toEntity());
return memberRepository.save(member);
}
}
return new DefaultOAuth2User(
userDetails.getAuthorities(),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
[HomeController.class]
@Controller
@RequiredArgsConstructor
public class HomeController {
@GetMapping("/")
public String home(Model model) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
model.addAttribute("signedMember", authentication.getName());
return "index";
}
}
<!DOCTYPE html>
<html lang="kr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Index Page</h1>
<p>로그인된 회원 : </p>
<h3 th:text="${signedMember}"></h3>
<a href="members/login">로그인</a>
</body>
</html>
authentication.getName() 을 이용해서, 현재 로그인된 회원의 이름을 표시한다.
[Member Table - DB]
username 으로 설정된 email 이 표시 되지 않고, 식별자가 표시 되는 것을 볼 수 있다.
변경후 - CustomOAuth2UserService.class
변경 부분 - DefaultOAuth2User() 의 세번째 파라미터를
"attributes.getNameAttributeKey()" 에서 "email" 로 변경하였다.
return new DefaultOAuth2User(
userDetails.getAuthorities(),
attributes.getAttributes(),
"email");
[Member Table - DB]
username 으로 설정된 email 이 그대로 표시 되는 것을 확인할 수 있다.