Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Google OAuth2 로그인 구현 #39

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {

// SOCIAL LOGIN
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'io.jsonwebtoken:jjwt:0.12.6'

// TEST
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package depromeet.onepiece.common.auth.application.dto;

import depromeet.onepiece.user.domain.OAuthProviderType;
import java.util.Map;

public interface AuthAttributes {

String getExternalId();

String getEmail();

OAuthProviderType getProvider();

static AuthAttributes of(String providerId, Map<String, Object> attributes) {
if (OAuthProviderType.GOOGLE.isProviderOf(providerId)) {
return GoogleAuthAttributes.of(attributes);
}
throw new IllegalArgumentException("Unsupported id: " + providerId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package depromeet.onepiece.common.auth.application.dto;

import depromeet.onepiece.user.domain.OAuthProviderType;
import java.util.Map;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class GoogleAuthAttributes implements AuthAttributes {

private final String id;
private final String email;
private final OAuthProviderType provide;

public static GoogleAuthAttributes of(Map<String, Object> attributes) {
Map<String, Object> google = (Map<String, Object>) attributes.get("google");

return new GoogleAuthAttributes(
attributes.get("sub").toString(), (String) google.get("email"), OAuthProviderType.GOOGLE);
}

@Override
public String getExternalId() {
return this.id;
}

@Override
public String getEmail() {
return this.email;
}

@Override
public OAuthProviderType getProvider() {
return this.provide;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package depromeet.onepiece.common.auth.application.handler;

import static depromeet.onepiece.common.auth.presentation.exception.AuthExceptionCode.ALREADY_REGISTERED_USER;
import static depromeet.onepiece.common.auth.presentation.filter.RedirectUrlFilter.REDIRECT_URL_COOKIE_NAME;

import depromeet.onepiece.common.auth.application.jwt.TokenInjector;
import depromeet.onepiece.common.auth.application.service.AuthService;
import depromeet.onepiece.common.auth.domain.CustomOAuth2User;
import depromeet.onepiece.common.auth.domain.jwt.LoginResult;
import depromeet.onepiece.common.auth.infrastructure.SecurityProperties;
import depromeet.onepiece.common.auth.presentation.exception.AlreadyRegisteredUserException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@RequiredArgsConstructor
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

private final AuthService authService;
private final TokenInjector tokenInjector;

private final SecurityProperties securityProperties;

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
try {
LoginResult result = resolveLoginResultFromAuthentication(authentication);
tokenInjector.injectTokensToCookie(result, response);
redirectToSuccessUrl(request, response);
} catch (AlreadyRegisteredUserException e) {
handleAlreadyExistUser(response);
}
}

private LoginResult resolveLoginResultFromAuthentication(Authentication authentication) {
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
return authService.handleLoginSuccess(oAuth2User.getAuthAttributes());
}

private void redirectToSuccessUrl(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String redirectUrlByCookie = getRedirectUrlByCookie(request);
String redirectUrl = determineRedirectUrl(redirectUrlByCookie);
response.sendRedirect(redirectUrl);
tokenInjector.invalidateCookie(REDIRECT_URL_COOKIE_NAME, response);
}

private String getRedirectUrlByCookie(HttpServletRequest request) {
return Arrays.stream(request.getCookies())
.filter(cookie -> Objects.equals(cookie.getName(), REDIRECT_URL_COOKIE_NAME))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}

private String determineRedirectUrl(String redirectCookie) {
if (StringUtils.hasText(redirectCookie)) {
return redirectCookie;
}
return securityProperties.redirectUrl();
}

private void handleAlreadyExistUser(HttpServletResponse response) throws IOException {
response.sendRedirect(
securityProperties.loginUrl() + "?error=true&exception=" + ALREADY_REGISTERED_USER);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package depromeet.onepiece.common.auth.application.jwt;

import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.ACCESS_TOKEN;
import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.REFRESH_TOKEN;

import depromeet.onepiece.common.auth.domain.jwt.LoginResult;
import depromeet.onepiece.common.auth.infrastructure.SecurityProperties;
import depromeet.onepiece.common.auth.infrastructure.jwt.TokenProperties;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class TokenInjector {

private final TokenProperties tokenProperties;
private final SecurityProperties securityProperties;

public void injectTokensToCookie(LoginResult result, HttpServletResponse response) {
int accessTokenMaxAge = (int) tokenProperties.expirationTime().accessToken() + 5;
int refreshTokenMaxAge = (int) tokenProperties.expirationTime().refreshToken() + 5;

addCookie(ACCESS_TOKEN, result.accessToken(), accessTokenMaxAge, response);
addCookie(REFRESH_TOKEN, result.refreshToken(), refreshTokenMaxAge, response);
}

public void addCookie(String name, String value, int maxAge, HttpServletResponse response) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(maxAge);
cookie.setHttpOnly(securityProperties.cookie().httpOnly());
cookie.setDomain(securityProperties.cookie().domain());
cookie.setSecure(securityProperties.cookie().secure());
cookie.setAttribute("SameSite", "None");

response.addCookie(cookie);
}

public void invalidateCookie(String name, HttpServletResponse response) {
Cookie cookie = new Cookie(name, null);
cookie.setPath("/");
cookie.setMaxAge(0);
cookie.setHttpOnly(securityProperties.cookie().httpOnly());
cookie.setDomain(securityProperties.cookie().domain());
cookie.setSecure(securityProperties.cookie().secure());
cookie.setAttribute("SameSite", "None");

response.addCookie(cookie);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package depromeet.onepiece.common.auth.application.service;

import depromeet.onepiece.common.auth.application.dto.AuthAttributes;
import depromeet.onepiece.common.auth.domain.RefreshToken;
import depromeet.onepiece.common.auth.domain.RefreshTokenRepository;
import depromeet.onepiece.common.auth.domain.jwt.LoginResult;
import depromeet.onepiece.common.auth.domain.jwt.TokenProvider;
import depromeet.onepiece.common.auth.infrastructure.jwt.TokenProperties;
import depromeet.onepiece.common.auth.presentation.exception.AlreadyRegisteredUserException;
import depromeet.onepiece.user.command.domain.UserCommandRepository;
import depromeet.onepiece.user.domain.User;
import depromeet.onepiece.user.query.domain.UserQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AuthService {

private final UserQueryRepository userQueryRepository;
private final UserCommandRepository userCommandRepository;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final TokenProperties tokenProperties;

@Transactional
public LoginResult handleLoginSuccess(AuthAttributes attributes) {
String email = attributes.getEmail();

return userQueryRepository
.findByEmail(email)
.map(user -> handleExistUser(user, attributes))
.orElseGet(() -> handleFirstLogin(attributes));
}

private LoginResult handleExistUser(User user, AuthAttributes attributes) {
if (user.hasDifferentProviderWithEmail(attributes.getEmail(), attributes.getExternalId())) {
throw new AlreadyRegisteredUserException();
}

return generateLoginResult(user, false);
}

private LoginResult handleFirstLogin(AuthAttributes attributes) {
User newUser = saveNewUser(attributes);
return generateLoginResult(newUser, true);
}

private LoginResult generateLoginResult(User user, boolean firstLogin) {
String accessToken = tokenProvider.generateAccessToken(user.getExternalId());
String refreshToken = tokenProvider.generateRefreshToken(user.getExternalId());

RefreshToken refreshTokenEntity =
refreshTokenRepository
.findByUserId(user.getId())
.orElse(
RefreshToken.of(
user.getExternalId(),
refreshToken,
tokenProperties.expirationTime().refreshToken()));

refreshTokenEntity.rotate(refreshToken);
refreshTokenRepository.save(refreshTokenEntity);

return new LoginResult(accessToken, refreshToken, firstLogin, user.getExternalId());
}

private User saveNewUser(AuthAttributes attributes) {
User user = User.save(attributes);
return userCommandRepository.save(user);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package depromeet.onepiece.common.auth.application.service;

import depromeet.onepiece.common.auth.domain.CustomUserDetails;
import depromeet.onepiece.user.domain.User;
import depromeet.onepiece.user.query.domain.UserQueryRepository;
import lombok.RequiredArgsConstructor;
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;

@RequiredArgsConstructor
@Service
public class OAuth2UserDetailsService implements UserDetailsService {

private final UserQueryRepository userQueryRepository;

@Override
public UserDetails loadUserByUsername(String externalId) throws UsernameNotFoundException {
User user =
userQueryRepository
.findUserByExternalId(externalId)
.orElseThrow(
() -> new UsernameNotFoundException("User not found with id: " + externalId));
return new CustomUserDetails(user.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package depromeet.onepiece.common.auth.application.service;

import depromeet.onepiece.common.auth.application.dto.AuthAttributes;
import depromeet.onepiece.common.auth.domain.CustomOAuth2User;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class OAuth2UserService extends DefaultOAuth2UserService {

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);

List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");

ClientRegistration clientRegistration = userRequest.getClientRegistration();
String registrationId = clientRegistration.getRegistrationId();
String userNameAttributeName =
clientRegistration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

AuthAttributes authAttributes = AuthAttributes.of(registrationId, oAuth2User.getAttributes());
return new CustomOAuth2User(
authorities, oAuth2User.getAttributes(), userNameAttributeName, authAttributes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package depromeet.onepiece.common.auth.domain;

import depromeet.onepiece.common.auth.application.dto.AuthAttributes;
import java.util.Collection;
import java.util.Map;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;

@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

private final AuthAttributes authAttributes;

public CustomOAuth2User(
Collection<? extends GrantedAuthority> authorities,
Map<String, Object> attributes,
String nameAttributeKey,
AuthAttributes authAttributes) {
super(authorities, attributes, nameAttributeKey);
this.authAttributes = authAttributes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package depromeet.onepiece.common.auth.domain;

import java.util.Collection;
import java.util.List;
import org.bson.types.ObjectId;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class CustomUserDetails implements UserDetails {

private final ObjectId userId;

public CustomUserDetails(ObjectId userId) {
this.userId = userId;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}

@Override
public String getPassword() {
return "";
}

@Override
public String getUsername() {
return this.userId.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package depromeet.onepiece.common.auth.domain.jwt;

public record LoginResult(
String accessToken, String refreshToken, boolean isNewUser, String externalId) {}
Loading
Loading