diff --git a/build.gradle b/build.gradle index ebb2c62a..85a2681c 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta' // Security + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation "org.springframework.security:spring-security-test" @@ -130,7 +131,8 @@ jacocoTestReport { '**/Q*.class', '**/entity/**', '**/domain/**/*Entity.class', - '**/domain/link/util/OgTagCrawler.class' + '**/domain/link/util/OgTagCrawler.class', + "**/security/auth/**" ]) })) } @@ -172,7 +174,8 @@ jacocoTestCoverageVerification { '**/Q*.class', '**/entity/**', '**/domain/**/*Entity.class', - '**/domain/link/util/OgTagCrawler.class' + '**/domain/link/util/OgTagCrawler.class', + "**/security/auth/**" ]) })) } diff --git a/src/main/java/com/sofa/linkiving/domain/member/service/MemberCommandService.java b/src/main/java/com/sofa/linkiving/domain/member/service/MemberCommandService.java index 564fa902..e19dcbb0 100644 --- a/src/main/java/com/sofa/linkiving/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/member/service/MemberCommandService.java @@ -20,4 +20,16 @@ public Member addUser(String email, String password) { return memberRepository.save(member); } + + public Member createOrUpdate(String email) { + return memberRepository.findByEmail(email) + .orElseGet(() -> { + Member newMember = Member.builder() + .email(email) + .password(email) + .build(); + + return memberRepository.save(newMember); + }); + } } diff --git a/src/main/java/com/sofa/linkiving/security/auth/code/AuthErrorCode.java b/src/main/java/com/sofa/linkiving/security/auth/code/AuthErrorCode.java new file mode 100644 index 00000000..9f859a24 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/code/AuthErrorCode.java @@ -0,0 +1,24 @@ +package com.sofa.linkiving.security.auth.code; + +import org.springframework.http.HttpStatus; + +import com.sofa.linkiving.global.error.code.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A-000", "로그인에 실패했습니다."), + INVALID_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, "A-001", "지원하지 않는 소셜 로그인입니다."), + AUTHORIZATION_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST, "A-002", "인증 요청 정보를 찾을 수 없습니다. (쿠키 누락 등)"), + USER_CANCELLED(HttpStatus.BAD_REQUEST, "A-003", "사용자가 로그인을 취소했습니다."), + PROVIDER_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "A-100", "소셜 공급자(Google) 서버 오류입니다."), + INTERNAL_AUTH_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "A-101", "인증 처리 중 서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sofa/linkiving/security/auth/config/OAuth2Properties.java b/src/main/java/com/sofa/linkiving/security/auth/config/OAuth2Properties.java new file mode 100644 index 00000000..a53c7f7e --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/config/OAuth2Properties.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.security.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.oauth2") +public record OAuth2Properties( + String successRedirectUrl, + String failureRedirectUrl +) { +} diff --git a/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2FailureHandler.java b/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2FailureHandler.java new file mode 100644 index 00000000..ba77ecd6 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2FailureHandler.java @@ -0,0 +1,57 @@ +package com.sofa.linkiving.security.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import com.sofa.linkiving.global.error.code.ErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; +import com.sofa.linkiving.security.auth.code.AuthErrorCode; +import com.sofa.linkiving.security.auth.config.OAuth2Properties; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private final OAuth2Properties oauth2Properties; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + + ErrorCode errorCode = AuthErrorCode.LOGIN_FAILED; + Throwable cause = exception.getCause(); + + if (cause instanceof BusinessException businessException) { + errorCode = businessException.getErrorCode(); + + } else if (exception instanceof OAuth2AuthenticationException oauthException) { + OAuth2Error error = oauthException.getError(); + errorCode = determineAuthErrorCode(error.getErrorCode()); + } + + String targetUrl = UriComponentsBuilder.fromUriString(oauth2Properties.failureRedirectUrl()) + .queryParam("code", errorCode.getCode()) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + private AuthErrorCode determineAuthErrorCode(String providerErrorCode) { + return switch (providerErrorCode) { + case "access_denied" -> AuthErrorCode.USER_CANCELLED; + case "invalid_client", "invalid_request" -> AuthErrorCode.INVALID_SOCIAL_PROVIDER; + case "server_error", "temporarily_unavailable" -> AuthErrorCode.PROVIDER_SERVER_ERROR; + default -> AuthErrorCode.LOGIN_FAILED; + }; + } +} diff --git a/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 00000000..77b32fae --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,62 @@ +package com.sofa.linkiving.security.auth.handler; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.security.auth.config.OAuth2Properties; +import com.sofa.linkiving.security.jwt.JwtProperties; +import com.sofa.linkiving.security.jwt.JwtTokenProvider; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final OAuth2Properties oauth2Properties; + private final JwtProperties jwtProperties; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal(); + String email = oAuth2User.getAttribute("email"); + + String accessToken = jwtTokenProvider.createAccessToken(email); + String refreshToken = jwtTokenProvider.createRefreshToken(email); + + int accessExp = (int)(jwtProperties.accessTokenValidTime() / 1000); + int refreshExp = (int)(jwtProperties.refreshTokenValidTime() / 1000); + + addCookie(request, response, "accessToken", accessToken, accessExp); + addCookie(request, response, "refreshToken", refreshToken, refreshExp); + + String targetUrl = oauth2Properties.successRedirectUrl(); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + private void addCookie(HttpServletRequest request, HttpServletResponse response, String name, String value, + int maxAge) { + String domain = request.getServerName(); + boolean isLocal = "localhost".equals(domain) || "127.0.0.1".equals(domain); + + ResponseCookie cookie = ResponseCookie.from(name, value) + .path("/") + .maxAge(maxAge) + .httpOnly(!isLocal) + .secure(!isLocal) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} diff --git a/src/main/java/com/sofa/linkiving/security/auth/info/GoogleOAuth2User.java b/src/main/java/com/sofa/linkiving/security/auth/info/GoogleOAuth2User.java new file mode 100644 index 00000000..6719ad5e --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/info/GoogleOAuth2User.java @@ -0,0 +1,19 @@ +package com.sofa.linkiving.security.auth.info; + +import java.util.Map; + +public record GoogleOAuth2User( + Map attributes, + String name, + String email, + String picture +) { + public GoogleOAuth2User(Map attributes) { + this( + attributes, + (String)attributes.get("name"), + (String)attributes.get("email"), + (String)attributes.get("picture") + ); + } +} diff --git a/src/main/java/com/sofa/linkiving/security/auth/service/CustomOAuth2UserService.java b/src/main/java/com/sofa/linkiving/security/auth/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..c972d870 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/security/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,43 @@ +package com.sofa.linkiving.security.auth.service; + +import java.util.Collections; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.domain.member.service.MemberCommandService; +import com.sofa.linkiving.security.auth.info.GoogleOAuth2User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberCommandService memberCommandService; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + GoogleOAuth2User googleUser = new GoogleOAuth2User(oAuth2User.getAttributes()); + + Member member = memberCommandService.createOrUpdate(googleUser.email()); + + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())), + googleUser.attributes(), + "sub" + ); + } +} diff --git a/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java b/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java index c3bac430..daed7494 100644 --- a/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java +++ b/src/main/java/com/sofa/linkiving/security/config/SecurityConfig.java @@ -12,6 +12,10 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import com.sofa.linkiving.security.auth.config.OAuth2Properties; +import com.sofa.linkiving.security.auth.handler.OAuth2FailureHandler; +import com.sofa.linkiving.security.auth.handler.OAuth2SuccessHandler; +import com.sofa.linkiving.security.auth.service.CustomOAuth2UserService; import com.sofa.linkiving.security.jwt.JwtProperties; import com.sofa.linkiving.security.jwt.entrypoint.CustomAuthenticationEntryPoint; import com.sofa.linkiving.security.jwt.filter.JwtAuthenticationFilter; @@ -22,7 +26,7 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor -@EnableConfigurationProperties(JwtProperties.class) +@EnableConfigurationProperties({JwtProperties.class, OAuth2Properties.class}) public class SecurityConfig { private static final String[] PERMIT_URLS = { @@ -42,16 +46,23 @@ public class SecurityConfig { "/ws/chat/**", /* temp */ - "/v1/member/**", "/mock/**" + "/v1/member/**", "/mock/**", + /* oauth2 */ + "/oauth2/**" }; private static final String[] SEMI_PERMIT_URLS = { //GET만 허용해야 하는 URL }; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtExceptionFilter jwtExceptionFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -66,6 +77,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(PERMIT_URLS).permitAll() .anyRequest().authenticated() ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) .exceptionHandling(exceptionConfig -> exceptionConfig.authenticationEntryPoint(customAuthenticationEntryPoint)) .headers(headers ->