Skip to content
Merged
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
7 changes: 5 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -130,7 +131,8 @@ jacocoTestReport {
'**/Q*.class',
'**/entity/**',
'**/domain/**/*Entity.class',
'**/domain/link/util/OgTagCrawler.class'
'**/domain/link/util/OgTagCrawler.class',
"**/security/auth/**"
])
}))
}
Expand Down Expand Up @@ -172,7 +174,8 @@ jacocoTestCoverageVerification {
'**/Q*.class',
'**/entity/**',
'**/domain/**/*Entity.class',
'**/domain/link/util/OgTagCrawler.class'
'**/domain/link/util/OgTagCrawler.class',
"**/security/auth/**"
])
}))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
};
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.sofa.linkiving.security.auth.info;

import java.util.Map;

public record GoogleOAuth2User(
Map<String, Object> attributes,
String name,
String email,
String picture
) {
public GoogleOAuth2User(Map<String, Object> attributes) {
this(
attributes,
(String)attributes.get("name"),
(String)attributes.get("email"),
(String)attributes.get("picture")
);
}
}
Original file line number Diff line number Diff line change
@@ -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<OAuth2UserRequest, OAuth2User> {

private final MemberCommandService memberCommandService;

@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> 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"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +26,7 @@
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableConfigurationProperties(JwtProperties.class)
@EnableConfigurationProperties({JwtProperties.class, OAuth2Properties.class})
public class SecurityConfig {

private static final String[] PERMIT_URLS = {
Expand All @@ -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
Expand All @@ -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 ->
Expand Down