diff --git a/build.gradle b/build.gradle index 1fcfe85..09266df 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/depromeet/onepiece/common/auth/application/dto/AuthAttributes.java b/src/main/java/depromeet/onepiece/common/auth/application/dto/AuthAttributes.java new file mode 100644 index 0000000..26804ca --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/dto/AuthAttributes.java @@ -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 attributes) { + if (OAuthProviderType.GOOGLE.isProviderOf(providerId)) { + return GoogleAuthAttributes.of(attributes); + } + throw new IllegalArgumentException("Unsupported id: " + providerId); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/dto/GoogleAuthAttributes.java b/src/main/java/depromeet/onepiece/common/auth/application/dto/GoogleAuthAttributes.java new file mode 100644 index 0000000..de3b06b --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/dto/GoogleAuthAttributes.java @@ -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 attributes) { + Map google = (Map) 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; + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/handler/OAuth2LoginSuccessHandler.java b/src/main/java/depromeet/onepiece/common/auth/application/handler/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..15058dd --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/handler/OAuth2LoginSuccessHandler.java @@ -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); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/jwt/TokenInjector.java b/src/main/java/depromeet/onepiece/common/auth/application/jwt/TokenInjector.java new file mode 100644 index 0000000..df53a75 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/jwt/TokenInjector.java @@ -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); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/service/AuthService.java b/src/main/java/depromeet/onepiece/common/auth/application/service/AuthService.java new file mode 100644 index 0000000..7b18845 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/service/AuthService.java @@ -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); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserDetailsService.java b/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserDetailsService.java new file mode 100644 index 0000000..b7050aa --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserDetailsService.java @@ -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()); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserService.java b/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserService.java new file mode 100644 index 0000000..91a6c89 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/application/service/OAuth2UserService.java @@ -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 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); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/domain/CustomOAuth2User.java b/src/main/java/depromeet/onepiece/common/auth/domain/CustomOAuth2User.java new file mode 100644 index 0000000..5698e05 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/domain/CustomOAuth2User.java @@ -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 authorities, + Map attributes, + String nameAttributeKey, + AuthAttributes authAttributes) { + super(authorities, attributes, nameAttributeKey); + this.authAttributes = authAttributes; + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/domain/CustomUserDetails.java b/src/main/java/depromeet/onepiece/common/auth/domain/CustomUserDetails.java new file mode 100644 index 0000000..d7f3970 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/domain/CustomUserDetails.java @@ -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 getAuthorities() { + return List.of(); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return this.userId.toString(); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/domain/jwt/LoginResult.java b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/LoginResult.java new file mode 100644 index 0000000..0eb92b2 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/LoginResult.java @@ -0,0 +1,4 @@ +package depromeet.onepiece.common.auth.domain.jwt; + +public record LoginResult( + String accessToken, String refreshToken, boolean isNewUser, String externalId) {} diff --git a/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenProvider.java b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenProvider.java new file mode 100644 index 0000000..a56f7da --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenProvider.java @@ -0,0 +1,8 @@ +package depromeet.onepiece.common.auth.domain.jwt; + +public interface TokenProvider { + + String generateAccessToken(String externalId); + + String generateRefreshToken(String externalId); +} diff --git a/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenResolver.java b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenResolver.java new file mode 100644 index 0000000..5089243 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/domain/jwt/TokenResolver.java @@ -0,0 +1,13 @@ +package depromeet.onepiece.common.auth.domain.jwt; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +public interface TokenResolver { + + Optional resolveTokenFromRequest(HttpServletRequest request); + + Optional resolveRefreshTokenFromRequest(HttpServletRequest request); + + String getSubjectFromToken(String token); +} diff --git a/src/main/java/depromeet/onepiece/common/auth/infrastructure/SecurityProperties.java b/src/main/java/depromeet/onepiece/common/auth/infrastructure/SecurityProperties.java new file mode 100644 index 0000000..68729cd --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/infrastructure/SecurityProperties.java @@ -0,0 +1,10 @@ +package depromeet.onepiece.common.auth.infrastructure; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(prefix = "spring.security") +public record SecurityProperties( + String loginUrl, String redirectUrl, @NestedConfigurationProperty Cookie cookie) { + public record Cookie(String domain, boolean httpOnly, boolean secure) {} +} diff --git a/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7905f94 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenProvider.java @@ -0,0 +1,47 @@ +package depromeet.onepiece.common.auth.infrastructure.jwt; + +import static io.jsonwebtoken.io.Decoders.BASE64; + +import depromeet.onepiece.common.auth.domain.jwt.TokenProvider; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class JwtTokenProvider implements TokenProvider { + + private final TokenProperties tokenProperties; + + @Override + public String generateAccessToken(String externalId) { + long currentTimeMillis = System.currentTimeMillis(); + Date now = new Date(currentTimeMillis); + Date expiration = + new Date(currentTimeMillis + tokenProperties.expirationTime().accessToken() * 1000); + SecretKey secretKey = Keys.hmacShaKeyFor(BASE64.decode(tokenProperties.secretKey())); + + return Jwts.builder() + .subject(String.valueOf(externalId)) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + @Override + public String generateRefreshToken(String externalId) { + long currentTimeMillis = System.currentTimeMillis(); + Date now = new Date(currentTimeMillis); + SecretKey secretKey = Keys.hmacShaKeyFor(BASE64.decode(tokenProperties.secretKey())); + + return Jwts.builder() + .subject(String.valueOf(externalId)) + .issuedAt(now) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenResolver.java b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenResolver.java new file mode 100644 index 0000000..4e46c92 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/JwtTokenResolver.java @@ -0,0 +1,78 @@ +package depromeet.onepiece.common.auth.infrastructure.jwt; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +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.TokenResolver; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Slf4j +@Component +public class JwtTokenResolver implements TokenResolver { + + private static final String REPLACE_BEARER_PATTERN = "^Bearer( )*"; + private static final Pattern BEARER_PATTERN = Pattern.compile("^Bearer .*"); + + private final SecretKey secretKey; + + public JwtTokenResolver(TokenProperties tokenProperties) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(tokenProperties.secretKey())); + } + + @Override + public Optional resolveTokenFromRequest(HttpServletRequest request) { + return resolveFromHeader(request).or(() -> resolveFromCookie(request, ACCESS_TOKEN)); + } + + @Override + public Optional resolveRefreshTokenFromRequest(HttpServletRequest request) { + return resolveFromCookie(request, REFRESH_TOKEN); + } + + @Override + public String getSubjectFromToken(String token) { + return getClaims(token, secretKey).getPayload().getSubject(); + } + + private static Optional resolveFromHeader(HttpServletRequest request) { + Iterator authorizations = request.getHeaders(AUTHORIZATION).asIterator(); + + return Optional.ofNullable(authorizations) + .filter(Iterator::hasNext) + .map(Iterator::next) + .filter(auth -> StringUtils.hasText(auth) && BEARER_PATTERN.matcher(auth).matches()) + .map(auth -> auth.replaceAll(REPLACE_BEARER_PATTERN, "")); + } + + private Optional resolveFromCookie(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (Objects.isNull(cookies)) { + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> Objects.equals(cookie.getName(), cookieName)) + .map(Cookie::getValue) + .findFirst(); + } + + private Jws getClaims(String token, SecretKey secretKey) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/TokenProperties.java b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/TokenProperties.java new file mode 100644 index 0000000..072a20e --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/infrastructure/jwt/TokenProperties.java @@ -0,0 +1,14 @@ +package depromeet.onepiece.common.auth.infrastructure.jwt; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties(prefix = "spring.security.jwt") +public record TokenProperties( + @NotNull String secretKey, + @NestedConfigurationProperty @NotNull ExpirationTime expirationTime) { + + public record ExpirationTime(@Min(0) long accessToken, @Min(0) long refreshToken) {} +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/LoginController.java b/src/main/java/depromeet/onepiece/common/auth/presentation/LoginController.java new file mode 100644 index 0000000..71b3bbc --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/LoginController.java @@ -0,0 +1,14 @@ +package depromeet.onepiece.common.auth.presentation; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/login") +public class LoginController { + @GetMapping + public String login() { + return "redirect:/oauth2/authorization/google"; + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AlreadyRegisteredUserException.java b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AlreadyRegisteredUserException.java new file mode 100644 index 0000000..e0bd6bf --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AlreadyRegisteredUserException.java @@ -0,0 +1,12 @@ +package depromeet.onepiece.common.auth.presentation.exception; + +import static depromeet.onepiece.common.auth.presentation.exception.AuthExceptionCode.ALREADY_REGISTERED_USER; + +import depromeet.onepiece.common.error.GlobalException; + +public class AlreadyRegisteredUserException extends GlobalException { + + public AlreadyRegisteredUserException() { + super(ALREADY_REGISTERED_USER.getMessage(), ALREADY_REGISTERED_USER); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthExceptionCode.java b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthExceptionCode.java new file mode 100644 index 0000000..6f952de --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthExceptionCode.java @@ -0,0 +1,26 @@ +package depromeet.onepiece.common.auth.presentation.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import depromeet.onepiece.common.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthExceptionCode implements ErrorCode { + ALREADY_REGISTERED_USER(BAD_REQUEST, "다른 소셜 계정으로 이미 가입된 사용자입니다."), + AUTHENTICATION_REQUIRED(UNAUTHORIZED, "인증 정보가 유효하지 않습니다."), + REFRESH_TOKEN_NOT_VALID(UNAUTHORIZED, "리프레시 토큰이 유효하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getCode() { + return this.name(); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthenticationRequiredException.java b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthenticationRequiredException.java new file mode 100644 index 0000000..8c03c74 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/exception/AuthenticationRequiredException.java @@ -0,0 +1,11 @@ +package depromeet.onepiece.common.auth.presentation.exception; + +import static depromeet.onepiece.common.auth.presentation.exception.AuthExceptionCode.AUTHENTICATION_REQUIRED; + +import depromeet.onepiece.common.error.GlobalException; + +public class AuthenticationRequiredException extends GlobalException { + public AuthenticationRequiredException() { + super(AUTHENTICATION_REQUIRED.getMessage(), AUTHENTICATION_REQUIRED); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/filter/JwtTokenFilter.java b/src/main/java/depromeet/onepiece/common/auth/presentation/filter/JwtTokenFilter.java new file mode 100644 index 0000000..4d8435b --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/filter/JwtTokenFilter.java @@ -0,0 +1,99 @@ +package depromeet.onepiece.common.auth.presentation.filter; + +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.application.jwt.TokenInjector; +import depromeet.onepiece.common.auth.application.service.RefreshTokenService; +import depromeet.onepiece.common.auth.domain.jwt.TokenResolver; +import depromeet.onepiece.common.auth.presentation.exception.AuthenticationRequiredException; +import depromeet.onepiece.common.auth.presentation.exception.RefreshTokenNotValidException; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtTokenFilter extends OncePerRequestFilter { + + private final TokenResolver tokenResolver; + private final TokenInjector tokenInjector; + private final UserDetailsService userDetailsService; + private final RefreshTokenService refreshTokenService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + processTokenAuthentication(request, response); + filterChain.doFilter(request, response); + } + + private void processTokenAuthentication( + HttpServletRequest request, HttpServletResponse response) { + try { + String token = resolveTokenFromRequest(request, response); + setAuthentication(request, getUserDetails(token, request, response)); + } catch (ExpiredJwtException | AuthenticationRequiredException e) { + log.debug("Failed to authenticate", e); + invalidateCookie(ACCESS_TOKEN, response); + } catch (RefreshTokenNotValidException e) { + log.warn("Failed to authenticate", e); + invalidateCookie(ACCESS_TOKEN, response); + invalidateCookie(REFRESH_TOKEN, response); + } catch (Exception e) { + log.error("Failed to authenticate", e); + invalidateCookie(ACCESS_TOKEN, response); + } + } + + private String resolveTokenFromRequest(HttpServletRequest request, HttpServletResponse response) { + try { + return tokenResolver + .resolveTokenFromRequest(request) + .orElseGet( + () -> + refreshTokenService.reissueBasedOnRefreshToken(request, response).accessToken()); + } catch (ExpiredJwtException e) { + return refreshTokenService.reissueBasedOnRefreshToken(request, response).accessToken(); + } + } + + private UserDetails getUserDetails( + String token, HttpServletRequest request, HttpServletResponse response) { + try { + String subject = tokenResolver.getSubjectFromToken(token); + return userDetailsService.loadUserByUsername(subject); + } catch (ExpiredJwtException e) { + String accessToken = + refreshTokenService.reissueBasedOnRefreshToken(request, response).accessToken(); + String subject = tokenResolver.getSubjectFromToken(accessToken); + return userDetailsService.loadUserByUsername(subject); + } + } + + private void setAuthentication(HttpServletRequest request, UserDetails userDetails) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void invalidateCookie(String cookieName, HttpServletResponse response) { + tokenInjector.invalidateCookie(cookieName, response); + SecurityContextHolder.clearContext(); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/filter/RedirectUrlFilter.java b/src/main/java/depromeet/onepiece/common/auth/presentation/filter/RedirectUrlFilter.java new file mode 100644 index 0000000..4fd020a --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/filter/RedirectUrlFilter.java @@ -0,0 +1,45 @@ +package depromeet.onepiece.common.auth.presentation.filter; + +import depromeet.onepiece.common.auth.application.jwt.TokenInjector; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Component +public class RedirectUrlFilter extends OncePerRequestFilter { + + private final TokenInjector tokenInjector; + + public static final String REDIRECT_URL_QUERY_PARAM = "redirectUrl"; + public static final String REDIRECT_URL_COOKIE_NAME = "redirect_url"; + private static final List REDIRECT_URL_INJECTION_PATTERNS = + List.of("/oauth2/authorization/.*"); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (isRedirectRequest(request)) { + tokenInjector.invalidateCookie(REDIRECT_URL_COOKIE_NAME, response); + String redirectUri = request.getParameter(REDIRECT_URL_QUERY_PARAM); + + if (StringUtils.hasText(redirectUri)) { + tokenInjector.addCookie(REDIRECT_URL_COOKIE_NAME, redirectUri, 3600, response); + } + } + + filterChain.doFilter(request, response); + } + + private boolean isRedirectRequest(HttpServletRequest request) { + return REDIRECT_URL_INJECTION_PATTERNS.stream().anyMatch(request.getRequestURI()::matches); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/handler/CustomAuthenticationFailureHandler.java b/src/main/java/depromeet/onepiece/common/auth/presentation/handler/CustomAuthenticationFailureHandler.java new file mode 100644 index 0000000..2cb3dd0 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/handler/CustomAuthenticationFailureHandler.java @@ -0,0 +1,30 @@ +package depromeet.onepiece.common.auth.presentation.handler; + +import depromeet.onepiece.common.auth.infrastructure.SecurityProperties; +import depromeet.onepiece.common.auth.presentation.exception.AuthExceptionCode; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private final SecurityProperties securityProperties; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException, ServletException { + super.setDefaultFailureUrl( + securityProperties.loginUrl() + + "?error=true&exception=" + + AuthExceptionCode.AUTHENTICATION_REQUIRED); + super.onAuthenticationFailure(request, response, exception); + } +} diff --git a/src/main/java/depromeet/onepiece/common/auth/presentation/handler/JwtAuthenticationEntryPoint.java b/src/main/java/depromeet/onepiece/common/auth/presentation/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..730c0e8 --- /dev/null +++ b/src/main/java/depromeet/onepiece/common/auth/presentation/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package depromeet.onepiece.common.auth.presentation.handler; + +import static depromeet.onepiece.common.auth.presentation.exception.AuthExceptionCode.AUTHENTICATION_REQUIRED; +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; + +import com.fasterxml.jackson.databind.ObjectMapper; +import depromeet.onepiece.common.error.CustomResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException { + setResponseBodyBasicInfo(response); + objectMapper.writeValue( + response.getOutputStream(), CustomResponse.error(AUTHENTICATION_REQUIRED)); + } + + private void setResponseBodyBasicInfo(HttpServletResponse response) { + response.setStatus(SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + } +} diff --git a/src/main/java/depromeet/onepiece/common/config/SecurityConfig.java b/src/main/java/depromeet/onepiece/common/config/SecurityConfig.java index 5803a90..eaa47fa 100644 --- a/src/main/java/depromeet/onepiece/common/config/SecurityConfig.java +++ b/src/main/java/depromeet/onepiece/common/config/SecurityConfig.java @@ -1,45 +1,94 @@ package depromeet.onepiece.common.config; +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +import depromeet.onepiece.common.auth.infrastructure.SecurityProperties; +import depromeet.onepiece.common.auth.presentation.filter.JwtTokenFilter; +import depromeet.onepiece.common.auth.presentation.filter.RedirectUrlFilter; import java.util.Collections; import java.util.List; +import lombok.RequiredArgsConstructor; 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.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final DefaultOAuth2UserService defaultOAuth2UserService; + private final AuthenticationSuccessHandler authenticationSuccessHandler; + private final AuthenticationFailureHandler authenticationFailureHandler; + private final JwtTokenFilter jwtTokenFilter; + private final RedirectUrlFilter redirectUrlFilter; + private final SecurityProperties securityProperties; + + private static final String[] PUBLIC_ENDPOINTS = { + "/api/v1/**", + }; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http.cors( - corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + disabledConfigurations(httpSecurity); + configurationSessionManagement(httpSecurity); + configurationCors(httpSecurity); + configureAuthorizeHttpRequests(httpSecurity); + configurationOAuth2Login(httpSecurity); + return httpSecurity.build(); + } + + private void disabledConfigurations(HttpSecurity httpSecurity) throws Exception { + httpSecurity .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests( - auth -> - auth.requestMatchers(SWAGGER_PATTERNS) - .permitAll() - .requestMatchers(STATIC_RESOURCES_PATTERNS) - .permitAll() - .requestMatchers(PERMIT_ALL_PATTERNS) - .permitAll() - .requestMatchers(PUBLIC_ENDPOINTS) - .permitAll() - .anyRequest() - .authenticated()) - // .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) - .build(); + .logout(AbstractHttpConfigurer::disable); + } + + private void configurationSessionManagement(HttpSecurity httpSecurity) throws Exception { + httpSecurity.sessionManagement(session -> session.sessionCreationPolicy(STATELESS)); + } + + private void configurationCors(HttpSecurity httpSecurity) throws Exception { + httpSecurity.cors( + corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())); + } + + private void configureAuthorizeHttpRequests(HttpSecurity httpSecurity) throws Exception { + httpSecurity.authorizeHttpRequests( + auth -> + auth.requestMatchers(SWAGGER_PATTERNS) + .permitAll() + .requestMatchers(STATIC_RESOURCES_PATTERNS) + .permitAll() + .requestMatchers(PERMIT_ALL_PATTERNS) + .permitAll() + .requestMatchers(PUBLIC_ENDPOINTS) + .permitAll() + .anyRequest() + .authenticated()); + } + + private void configurationOAuth2Login(HttpSecurity httpSecurity) throws Exception { + // httpSecurity.addFilterBefore(redirectUrlFilter, + // OAuth2AuthorizationRequestRedirectFilter.class); + httpSecurity.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); + httpSecurity.oauth2Login( + oauth2 -> + oauth2 + .loginPage(securityProperties.loginUrl()) + .userInfoEndpoint(userInfo -> userInfo.userService(defaultOAuth2UserService)) + .successHandler(authenticationSuccessHandler) + .failureHandler(authenticationFailureHandler)); } private static final String[] SWAGGER_PATTERNS = { @@ -51,11 +100,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }; private static final String[] PERMIT_ALL_PATTERNS = { - "/error", "/favicon.ico", "/index.html", "/", - }; - - private static final String[] PUBLIC_ENDPOINTS = { - "/api/v1/**", + "/error", "/index.html", "/login/**", "/oauth2/**", "/login/oauth2/**", }; CorsConfigurationSource corsConfigurationSource() { diff --git a/src/main/java/depromeet/onepiece/user/command/domain/UserCommandRepository.java b/src/main/java/depromeet/onepiece/user/command/domain/UserCommandRepository.java new file mode 100644 index 0000000..16453e5 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/command/domain/UserCommandRepository.java @@ -0,0 +1,7 @@ +package depromeet.onepiece.user.command.domain; + +import depromeet.onepiece.user.domain.User; + +public interface UserCommandRepository { + User save(User user); +} diff --git a/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandMongoRepository.java b/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandMongoRepository.java new file mode 100644 index 0000000..ff2dc37 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandMongoRepository.java @@ -0,0 +1,7 @@ +package depromeet.onepiece.user.command.infrastructure; + +import depromeet.onepiece.user.domain.User; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface UserCommandMongoRepository extends MongoRepository {} diff --git a/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandRepositoryImpl.java b/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandRepositoryImpl.java new file mode 100644 index 0000000..7f31750 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/command/infrastructure/UserCommandRepositoryImpl.java @@ -0,0 +1,17 @@ +package depromeet.onepiece.user.command.infrastructure; + +import depromeet.onepiece.user.command.domain.UserCommandRepository; +import depromeet.onepiece.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserCommandRepositoryImpl implements UserCommandRepository { + private final UserCommandMongoRepository userCommandMongoRepository; + + @Override + public User save(User user) { + return userCommandMongoRepository.save(user); + } +} diff --git a/src/main/java/depromeet/onepiece/user/domain/OAuthProviderType.java b/src/main/java/depromeet/onepiece/user/domain/OAuthProviderType.java index 152a716..793cfc6 100644 --- a/src/main/java/depromeet/onepiece/user/domain/OAuthProviderType.java +++ b/src/main/java/depromeet/onepiece/user/domain/OAuthProviderType.java @@ -1,5 +1,11 @@ package depromeet.onepiece.user.domain; +import java.util.Objects; + public enum OAuthProviderType { - GOOGLE + GOOGLE; + + public boolean isProviderOf(String providerId) { + return Objects.equals(this.name(), providerId); + } } diff --git a/src/main/java/depromeet/onepiece/user/domain/User.java b/src/main/java/depromeet/onepiece/user/domain/User.java index fdf874d..9f3a88c 100644 --- a/src/main/java/depromeet/onepiece/user/domain/User.java +++ b/src/main/java/depromeet/onepiece/user/domain/User.java @@ -1,11 +1,16 @@ package depromeet.onepiece.user.domain; +import static lombok.AccessLevel.PRIVATE; import static lombok.AccessLevel.PROTECTED; +import depromeet.onepiece.common.auth.application.dto.AuthAttributes; import depromeet.onepiece.common.domain.BaseTimeDocument; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.bson.types.ObjectId; @@ -15,8 +20,10 @@ import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.MongoId; +@Builder @Document @NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor(access = PRIVATE) @CompoundIndexes( value = @CompoundIndex(unique = true, name = "email_1_provider_1", def = "email_1_provider_1")) @Getter @@ -34,4 +41,22 @@ public class User extends BaseTimeDocument { @Field("provider") @NotNull(message = "인증 제공자는 필수 입력값입니다") private OAuthProviderType provider; + + @Field("external_id") + private String externalId; + + @Field("authorization_code") + private String authorizationCode; + + public static User save(AuthAttributes authAttributes) { + return User.builder() + .email(authAttributes.getEmail()) + .provider(authAttributes.getProvider()) + .externalId(authAttributes.getExternalId()) + .build(); + } + + public boolean hasDifferentProviderWithEmail(String email, String externalId) { + return Objects.equals(this.email, email) && !Objects.equals(this.externalId, externalId); + } } diff --git a/src/main/java/depromeet/onepiece/user/query/domain/UserQueryRepository.java b/src/main/java/depromeet/onepiece/user/query/domain/UserQueryRepository.java new file mode 100644 index 0000000..2ea6c50 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/query/domain/UserQueryRepository.java @@ -0,0 +1,11 @@ +package depromeet.onepiece.user.query.domain; + +import depromeet.onepiece.user.domain.User; +import java.util.Optional; + +public interface UserQueryRepository { + + Optional findByEmail(String email); + + Optional findUserByExternalId(String externalId); +} diff --git a/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryMongoRepository.java b/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryMongoRepository.java new file mode 100644 index 0000000..1a9c8c1 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryMongoRepository.java @@ -0,0 +1,12 @@ +package depromeet.onepiece.user.query.infrastructure; + +import depromeet.onepiece.user.domain.User; +import java.util.Optional; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface UserQueryMongoRepository extends MongoRepository { + Optional findByEmail(String email); + + User findByExternalId(String externalId); +} diff --git a/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryRepositoryImpl.java b/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryRepositoryImpl.java new file mode 100644 index 0000000..cbccb22 --- /dev/null +++ b/src/main/java/depromeet/onepiece/user/query/infrastructure/UserQueryRepositoryImpl.java @@ -0,0 +1,23 @@ +package depromeet.onepiece.user.query.infrastructure; + +import depromeet.onepiece.user.domain.User; +import depromeet.onepiece.user.query.domain.UserQueryRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserQueryRepositoryImpl implements UserQueryRepository { + private final UserQueryMongoRepository userQueryMongoRepository; + + @Override + public Optional findByEmail(String email) { + return userQueryMongoRepository.findByEmail(email); + } + + @Override + public Optional findUserByExternalId(String externalId) { + return Optional.ofNullable(userQueryMongoRepository.findByExternalId(externalId)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cd72716..aca0953 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,13 @@ spring: security: + login-url: ${LOGIN_URL:/login} + redirect-url: ${REDIRECT_URL:/} + cookie: + domain: ${COOKIE_DOMAIN:localhost} + secure: ${COOKIE_SECURE:false} + http-only: ${COOKIE_HTTP_ONLY:true} + jwt: + secret_key: ${JWT_SECRET_KEY} oauth2: client: registration: @@ -7,6 +15,7 @@ spring: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} scope: profile, email + redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/index.html} servlet: multipart: diff --git a/src/main/resources/static/critix-logo.png b/src/main/resources/static/critix-logo.png new file mode 100644 index 0000000..75abaf9 Binary files /dev/null and b/src/main/resources/static/critix-logo.png differ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..e945379 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,13 @@ + + + + + 로그인 완료 + + + + + diff --git a/src/main/resources/static/login.html b/src/main/resources/static/login.html new file mode 100644 index 0000000..470e22f --- /dev/null +++ b/src/main/resources/static/login.html @@ -0,0 +1,171 @@ + + + + + 로그인 페이지 + + + + + +
+ +
+ + \ No newline at end of file