google oauth2 적용
This commit is contained in:
parent
544a653f47
commit
54d76e6eb4
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||||
|
|
||||||
// ---- OpenAPI / Swagger UI ----
|
// ---- OpenAPI / Swagger UI ----
|
||||||
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public class AuthenticationService {
|
||||||
private final AuthCookieService authCookieService;
|
private final AuthCookieService authCookieService;
|
||||||
private final UserDetailsService userDetailsService;
|
private final UserDetailsService userDetailsService;
|
||||||
|
|
||||||
// ===================== 로그인 =====================
|
// ===================== 로그인 (비밀번호) =====================
|
||||||
|
|
||||||
public AuthenticationResponse authenticate(
|
public AuthenticationResponse authenticate(
|
||||||
AuthenticationRequest request,
|
AuthenticationRequest request,
|
||||||
|
|
@ -55,6 +55,16 @@ public class AuthenticationService {
|
||||||
throw new InvalidPasswordException("Invalid password");
|
throw new InvalidPasswordException("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 발급 로직은 공용 메서드로 위임 (Google 로그인과 동일 경로)
|
||||||
|
return issueTokens(employee, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 토큰 발급 (공용) =====================
|
||||||
|
// 비밀번호 로그인 / Google(OAuth2) 로그인 모두 이 메서드로 토큰을 발급한다.
|
||||||
|
// 호출자는 Employee 를 확보한 뒤 이 메서드를 부른다.
|
||||||
|
|
||||||
|
public AuthenticationResponse issueTokens(Employee employee, HttpServletResponse response) {
|
||||||
|
|
||||||
List<EmployeeRole> activeRoles =
|
List<EmployeeRole> activeRoles =
|
||||||
employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
|
employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId());
|
||||||
|
|
||||||
|
|
@ -65,11 +75,9 @@ public class AuthenticationService {
|
||||||
String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions);
|
String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions);
|
||||||
|
|
||||||
revokeAllEmployeeTokens(employee);
|
revokeAllEmployeeTokens(employee);
|
||||||
// refresh 도 저장
|
|
||||||
saveEmployeeToken(employee, accessToken, TokenType.ACCESS);
|
saveEmployeeToken(employee, accessToken, TokenType.ACCESS);
|
||||||
saveEmployeeToken(employee, refreshToken, TokenType.REFRESH);
|
saveEmployeeToken(employee, refreshToken, TokenType.REFRESH);
|
||||||
|
|
||||||
|
|
||||||
// Cookie로 내려줌
|
// Cookie로 내려줌
|
||||||
authCookieService.addAuthCookie(response, accessToken);
|
authCookieService.addAuthCookie(response, accessToken);
|
||||||
authCookieService.addRefreshCookie(response, refreshToken);
|
authCookieService.addRefreshCookie(response, refreshToken);
|
||||||
|
|
@ -118,7 +126,6 @@ public class AuthenticationService {
|
||||||
List<String> roles = extractRoles(activeRoles);
|
List<String> roles = extractRoles(activeRoles);
|
||||||
List<String> permissions = extractPermissions(activeRoles);
|
List<String> permissions = extractPermissions(activeRoles);
|
||||||
|
|
||||||
|
|
||||||
String newAccessToken = jwtService.generateToken(employee, roles, permissions);
|
String newAccessToken = jwtService.generateToken(employee, roles, permissions);
|
||||||
String newRefreshToken = jwtService.generateRefreshToken(employee, roles, permissions);
|
String newRefreshToken = jwtService.generateRefreshToken(employee, roles, permissions);
|
||||||
|
|
||||||
|
|
@ -138,34 +145,34 @@ public class AuthenticationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===================== System Token =====================
|
// ===================== System Token =====================
|
||||||
// OPR / CRM 등 서버간 호출용 (Header 기반)
|
// OPR / CRM 등 서버간 호출용 (Header 기반)
|
||||||
|
|
||||||
public SystemAuthenticationResponseDto authenticateSystem(SystemAuthenticationRequestDto request) {
|
public SystemAuthenticationResponseDto authenticateSystem(SystemAuthenticationRequestDto request) {
|
||||||
|
|
||||||
if (request.getClientId() == null || request.getClientSecret() == null) {
|
if (request.getClientId() == null || request.getClientSecret() == null) {
|
||||||
throw new InvalidPasswordException("Missing client credentials");
|
throw new InvalidPasswordException("Missing client credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientConfig = systemClientsProperties.getClients().get(request.getClientId());
|
var clientConfig = systemClientsProperties.getClients().get(request.getClientId());
|
||||||
if (clientConfig == null) {
|
if (clientConfig == null) {
|
||||||
throw new InvalidPasswordException("Invalid system client");
|
throw new InvalidPasswordException("Invalid system client");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientConfig.getSecret().equals(request.getClientSecret())) {
|
if (!clientConfig.getSecret().equals(request.getClientSecret())) {
|
||||||
throw new InvalidPasswordException("Invalid system secret");
|
throw new InvalidPasswordException("Invalid system secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
String jwtToken = jwtService.generateSystemToken(
|
String jwtToken = jwtService.generateSystemToken(
|
||||||
request.getClientId(),
|
request.getClientId(),
|
||||||
clientConfig.getPermissions()
|
clientConfig.getPermissions()
|
||||||
);
|
);
|
||||||
|
|
||||||
return SystemAuthenticationResponseDto.builder()
|
return SystemAuthenticationResponseDto.builder()
|
||||||
.accessToken(jwtToken)
|
.accessToken(jwtToken)
|
||||||
.refreshToken(null)
|
.refreshToken(null)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===================== 내부 헬퍼 =====================
|
// ===================== 내부 헬퍼 =====================
|
||||||
|
|
@ -199,6 +206,6 @@ public class AuthenticationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void revokeAllEmployeeTokens(Employee employee) {
|
private void revokeAllEmployeeTokens(Employee employee) {
|
||||||
tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId());
|
tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ 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.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||||
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||||
|
|
@ -21,6 +23,12 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
|
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
|
||||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||||
|
|
||||||
|
import com.goi.erp.oauth.HostedDomainAuthorizationRequestResolver;
|
||||||
|
import com.goi.erp.oauth.HttpCookieOAuth2AuthorizationRequestRepository;
|
||||||
|
import com.goi.erp.oauth.OAuth2LoginFailureHandler;
|
||||||
|
import com.goi.erp.oauth.OAuth2LoginSuccessHandler;
|
||||||
|
import com.goi.erp.oauth.OAuth2Properties;
|
||||||
|
|
||||||
//import static com.goi.erp.user.Permission.ADMIN_CREATE;
|
//import static com.goi.erp.user.Permission.ADMIN_CREATE;
|
||||||
//import static com.goi.erp.user.Permission.ADMIN_DELETE;
|
//import static com.goi.erp.user.Permission.ADMIN_DELETE;
|
||||||
//import static com.goi.erp.user.Permission.ADMIN_READ;
|
//import static com.goi.erp.user.Permission.ADMIN_READ;
|
||||||
|
|
@ -45,8 +53,11 @@ import java.util.List;
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class SecurityConfiguration {
|
public class SecurityConfiguration {
|
||||||
|
|
||||||
|
// NOTE: "/auth/**" 를 통째로 permitAll 하면 /auth/me 도 인증 없이 통과되어
|
||||||
|
// (access 토큰 만료 시) 200 {authenticated:false} 를 돌려주고, 프론트의
|
||||||
|
// 401-기반 refresh 가 트리거되지 않는다. 공개가 필요한 auth 엔드포인트만
|
||||||
|
// 아래 authorizeHttpRequests 에서 개별적으로 permitAll 한다.
|
||||||
private static final String[] WHITE_LIST_URL = {
|
private static final String[] WHITE_LIST_URL = {
|
||||||
"/auth/**",
|
|
||||||
"/v2/api-docs",
|
"/v2/api-docs",
|
||||||
"/v3/api-docs",
|
"/v3/api-docs",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
|
|
@ -61,16 +72,23 @@ public class SecurityConfiguration {
|
||||||
private final AuthenticationProvider authenticationProvider;
|
private final AuthenticationProvider authenticationProvider;
|
||||||
private final LogoutHandler logoutHandler;
|
private final LogoutHandler logoutHandler;
|
||||||
|
|
||||||
|
// ---- OAuth2 (Google Workspace) ----
|
||||||
|
private final HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository;
|
||||||
|
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
|
||||||
|
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
|
||||||
|
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||||
|
private final OAuth2Properties oAuth2Properties;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
|
||||||
config.setAllowedOriginPatterns(List.of(
|
config.setAllowedOriginPatterns(List.of(
|
||||||
"https://portal.greenoilinc.com",
|
"https://portal.greenoilinc.com",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://192.168.*.*:5173",
|
"http://192.168.*.*:5173",
|
||||||
"http://10.*.*.*:5173"
|
"http://10.*.*.*:5173"
|
||||||
));
|
));
|
||||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN"));
|
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN"));
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|
@ -83,15 +101,24 @@ public class SecurityConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
|
||||||
|
// hd 파라미터를 주입하는 커스텀 인가요청 리졸버
|
||||||
|
OAuth2AuthorizationRequestResolver authorizationRequestResolver =
|
||||||
|
new HostedDomainAuthorizationRequestResolver(
|
||||||
|
clientRegistrationRepository,
|
||||||
|
oAuth2Properties.getAllowedDomain());
|
||||||
|
|
||||||
http
|
http
|
||||||
.cors(Customizer.withDefaults())
|
.cors(Customizer.withDefaults())
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.authorizeHttpRequests(req ->
|
.authorizeHttpRequests(req ->
|
||||||
req.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
req.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
|
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
|
||||||
.requestMatchers("/auth/login", "/auth/token/refresh", "/auth/login/system").permitAll()
|
.requestMatchers("/auth/login", "/auth/token/refresh", "/auth/login/system").permitAll()
|
||||||
.requestMatchers("/auth/logout").authenticated()
|
// OAuth2 로그인 엔드포인트 (인가 시작 + Google 콜백)
|
||||||
.requestMatchers(WHITE_LIST_URL)
|
.requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
|
||||||
|
.requestMatchers("/auth/logout").authenticated()
|
||||||
|
.requestMatchers(WHITE_LIST_URL)
|
||||||
.permitAll()
|
.permitAll()
|
||||||
// .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name())
|
// .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name())
|
||||||
// .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name())
|
// .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name())
|
||||||
|
|
@ -101,6 +128,26 @@ public class SecurityConfiguration {
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated()
|
.authenticated()
|
||||||
)
|
)
|
||||||
|
// OAuth2 로그인 (Google Workspace). SPA 가 명시적으로 /oauth2/authorization/google 로
|
||||||
|
// 보낼 때만 시작된다. 보호 리소스 접근 시 자동 로그인 리다이렉트는 아래 401 entryPoint 가
|
||||||
|
// 우선하므로 일어나지 않는다(= API/SPA 는 401 받고 refresh 로직이 동작).
|
||||||
|
.oauth2Login(oauth2 -> oauth2
|
||||||
|
.authorizationEndpoint(authz -> authz
|
||||||
|
.baseUri("/oauth2/authorization")
|
||||||
|
.authorizationRequestRepository(cookieAuthorizationRequestRepository)
|
||||||
|
.authorizationRequestResolver(authorizationRequestResolver)
|
||||||
|
)
|
||||||
|
.redirectionEndpoint(redir -> redir
|
||||||
|
.baseUri("/login/oauth2/code/*")
|
||||||
|
)
|
||||||
|
.successHandler(oAuth2LoginSuccessHandler)
|
||||||
|
.failureHandler(oAuth2LoginFailureHandler)
|
||||||
|
)
|
||||||
|
// 미인증 요청은 403이 아니라 401 로 응답한다.
|
||||||
|
// (Spring Security 기본값은 보통 403 이라 프론트의 401-기반 refresh 가 안 걸린다)
|
||||||
|
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||||
|
(request, resp, authException) ->
|
||||||
|
resp.setStatus(jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED)))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
|
||||||
.authenticationProvider(authenticationProvider)
|
.authenticationProvider(authenticationProvider)
|
||||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.goi.erp.oauth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||||
|
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||||
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
|
||||||
|
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google 인가 요청에 hd(hosted domain) 파라미터를 붙인다.
|
||||||
|
*
|
||||||
|
* - 회사 도메인(greenoilinc.com) 계정으로 로그인 흐름을 유도하고 계정 선택 화면을 줄여준다.
|
||||||
|
* - 단, hd 파라미터는 "힌트"일 뿐 보안 경계가 아니다. 실제 도메인 강제는
|
||||||
|
* 성공 핸들러에서 ID 토큰의 hd 클레임을 검증하는 것으로 한다(Step 3).
|
||||||
|
*/
|
||||||
|
public class HostedDomainAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
|
||||||
|
|
||||||
|
private final DefaultOAuth2AuthorizationRequestResolver defaultResolver;
|
||||||
|
private final String hostedDomain;
|
||||||
|
|
||||||
|
public HostedDomainAuthorizationRequestResolver(ClientRegistrationRepository repo, String hostedDomain) {
|
||||||
|
// 기본 인가 엔드포인트 base-uri 와 동일하게 맞춤
|
||||||
|
this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
|
||||||
|
this.hostedDomain = hostedDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
|
||||||
|
return customize(defaultResolver.resolve(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
|
||||||
|
return customize(defaultResolver.resolve(request, clientRegistrationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest req) {
|
||||||
|
if (req == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hostedDomain == null || hostedDomain.isBlank()) {
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
Map<String, Object> extra = new HashMap<>(req.getAdditionalParameters());
|
||||||
|
extra.put("hd", hostedDomain);
|
||||||
|
return OAuth2AuthorizationRequest.from(req)
|
||||||
|
.additionalParameters(extra)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package com.goi.erp.oauth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
|
||||||
|
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.ObjectInputFilter;
|
||||||
|
import java.io.ObjectInputStream;
|
||||||
|
import java.io.ObjectOutputStream;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 대신 쿠키에 OAuth2AuthorizationRequest 를 저장한다.
|
||||||
|
*
|
||||||
|
* 앱이 SessionCreationPolicy.STATELESS 라 기본 세션 기반 저장소를 못 쓰기 때문에 필요.
|
||||||
|
* - 인가 요청 → Google 리다이렉트 시 쿠키에 저장
|
||||||
|
* - Google → 콜백 복귀 시 쿠키에서 복원 후 삭제
|
||||||
|
*
|
||||||
|
* 쿠키는 httpOnly + 짧은 수명(180s) + SameSite=Lax.
|
||||||
|
* (Google 에서 콜백으로 돌아오는 top-level GET 내비게이션에 쿠키가 실리려면 Lax 여야 함. Strict 면 안 됨)
|
||||||
|
* 역직렬화는 ObjectInputFilter 로 허용 클래스를 제한해 gadget 공격을 차단한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class HttpCookieOAuth2AuthorizationRequestRepository
|
||||||
|
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
|
||||||
|
|
||||||
|
public static final String OAUTH2_AUTH_REQUEST_COOKIE = "OAUTH2_AUTH_REQUEST";
|
||||||
|
private static final int COOKIE_EXPIRE_SECONDS = 180;
|
||||||
|
|
||||||
|
@Value("${auth.cookie.secure:true}")
|
||||||
|
private boolean secure;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
|
||||||
|
return getCookie(request, OAUTH2_AUTH_REQUEST_COOKIE)
|
||||||
|
.map(c -> deserialize(c.getValue()))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
if (authorizationRequest == null) {
|
||||||
|
deleteCookie(response, OAUTH2_AUTH_REQUEST_COOKIE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addCookie(response, OAUTH2_AUTH_REQUEST_COOKIE,
|
||||||
|
serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
OAuth2AuthorizationRequest authRequest = loadAuthorizationRequest(request);
|
||||||
|
if (authRequest != null) {
|
||||||
|
deleteCookie(response, OAUTH2_AUTH_REQUEST_COOKIE);
|
||||||
|
}
|
||||||
|
return authRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 직렬화 =====================
|
||||||
|
|
||||||
|
private static String serialize(OAuth2AuthorizationRequest obj) {
|
||||||
|
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
|
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
|
||||||
|
oos.writeObject(obj);
|
||||||
|
oos.flush();
|
||||||
|
return Base64.getUrlEncoder().encodeToString(bos.toByteArray());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to serialize OAuth2AuthorizationRequest", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OAuth2AuthorizationRequest deserialize(String value) {
|
||||||
|
try {
|
||||||
|
byte[] data = Base64.getUrlDecoder().decode(value);
|
||||||
|
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) {
|
||||||
|
ois.setObjectInputFilter(SAFE_FILTER);
|
||||||
|
return (OAuth2AuthorizationRequest) ois.readObject();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 위변조/손상 쿠키 → 인가요청 없음으로 처리
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth2AuthorizationRequest 와 그 필드(표준 java + spring-security-oauth2)만 허용 */
|
||||||
|
private static final ObjectInputFilter SAFE_FILTER = info -> {
|
||||||
|
Class<?> clazz = info.serialClass();
|
||||||
|
if (clazz == null) {
|
||||||
|
return ObjectInputFilter.Status.UNDECIDED;
|
||||||
|
}
|
||||||
|
String name = clazz.getName();
|
||||||
|
if (name.startsWith("org.springframework.security.oauth2.")
|
||||||
|
|| name.startsWith("java.")
|
||||||
|
|| name.startsWith("[Ljava.")
|
||||||
|
|| name.startsWith("[B")) {
|
||||||
|
return ObjectInputFilter.Status.ALLOWED;
|
||||||
|
}
|
||||||
|
return ObjectInputFilter.Status.REJECTED;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================== 쿠키 헬퍼 =====================
|
||||||
|
|
||||||
|
private void addCookie(HttpServletResponse response, String name, String value, int maxAgeSeconds) {
|
||||||
|
ResponseCookie cookie = ResponseCookie.from(name, value)
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(secure)
|
||||||
|
.path("/")
|
||||||
|
.sameSite("Lax")
|
||||||
|
.maxAge(maxAgeSeconds)
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteCookie(HttpServletResponse response, String name) {
|
||||||
|
ResponseCookie cookie = ResponseCookie.from(name, "")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(secure)
|
||||||
|
.path("/")
|
||||||
|
.sameSite("Lax")
|
||||||
|
.maxAge(0)
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Cookie> getCookie(HttpServletRequest request, String name) {
|
||||||
|
if (request.getCookies() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Arrays.stream(request.getCookies())
|
||||||
|
.filter(c -> name.equals(c.getName()))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.goi.erp.oauth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 로그인 실패 시 401 + 에러 메시지(JSON).
|
||||||
|
* 기본 핸들러는 /login?error 로 리다이렉트하는데 그런 화면이 없으므로 명시적으로 처리.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationFailure(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuthenticationException exception) throws IOException {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
String msg = exception.getMessage() == null ? "" : exception.getMessage().replace("\"", "'");
|
||||||
|
response.getWriter().write(
|
||||||
|
"{\"error\":\"oauth2_login_failed\",\"message\":\"" + msg + "\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.goi.erp.oauth;
|
||||||
|
|
||||||
|
import com.goi.erp.auth.AuthenticationService;
|
||||||
|
import com.goi.erp.employee.Employee;
|
||||||
|
import com.goi.erp.employee.EmployeeRepository;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google(OAuth2) 로그인 성공 후처리.
|
||||||
|
*
|
||||||
|
* 1) hd 클레임으로 도메인 검증 (greenoilinc.com)
|
||||||
|
* 2) 이메일(emp_login_id)로 Employee 조회
|
||||||
|
* 3) 활성 상태 확인
|
||||||
|
* 4) 기존 발급 경로(AuthenticationService.issueTokens) 재사용 → JWT + 쿠키
|
||||||
|
* 5) SPA(success-redirect)로 리다이렉트
|
||||||
|
*
|
||||||
|
* 실패 케이스는 SPA 로 ?login_error=코드 를 붙여 리다이렉트한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private final OAuth2Properties oAuth2Properties;
|
||||||
|
private final EmployeeRepository employeeRepository;
|
||||||
|
private final AuthenticationService authenticationService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Authentication authentication) throws IOException {
|
||||||
|
|
||||||
|
OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
|
||||||
|
String email = oidcUser.getEmail();
|
||||||
|
String hd = (String) oidcUser.getClaims().get("hd");
|
||||||
|
|
||||||
|
// 1) 도메인 검증
|
||||||
|
String allowed = oAuth2Properties.getAllowedDomain();
|
||||||
|
if (allowed != null && !allowed.isBlank() && !allowed.equalsIgnoreCase(hd)) {
|
||||||
|
redirectError(response, "domain_not_allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 이메일로 Employee 조회 (emp_login_id == Google Workspace 이메일)
|
||||||
|
Employee employee = employeeRepository.findByEmpLoginId(email).orElse(null);
|
||||||
|
if (employee == null) {
|
||||||
|
redirectError(response, "not_registered");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 활성 상태 확인 ('A' = Active)
|
||||||
|
if (!"A".equals(employee.getEmpStatus())) {
|
||||||
|
redirectError(response, "inactive");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 기존 발급 경로 재사용 (비밀번호 로그인과 동일): JWT 발급 + AUTH/REFRESH 쿠키 세팅
|
||||||
|
authenticationService.issueTokens(employee, response);
|
||||||
|
|
||||||
|
// 5) SPA 로 리다이렉트 (쿠키는 위 302 응답에 함께 실려 나간다)
|
||||||
|
response.sendRedirect(oAuth2Properties.getSuccessRedirect());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void redirectError(HttpServletResponse response, String code) throws IOException {
|
||||||
|
String base = oAuth2Properties.getSuccessRedirect();
|
||||||
|
String sep = base.contains("?") ? "&" : "?";
|
||||||
|
response.sendRedirect(base + sep + "login_error="
|
||||||
|
+ URLEncoder.encode(code, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.goi.erp.oauth;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* application.yml 의 app.oauth2.* 바인딩.
|
||||||
|
*
|
||||||
|
* app:
|
||||||
|
* oauth2:
|
||||||
|
* allowed-domain: greenoilinc.com
|
||||||
|
* success-redirect: https://portal.greenoilinc.com
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.oauth2")
|
||||||
|
public class OAuth2Properties {
|
||||||
|
|
||||||
|
/** 로그인 허용 Google Workspace 도메인 (hd 클레임 검증 + hd 파라미터) */
|
||||||
|
private String allowedDomain;
|
||||||
|
|
||||||
|
/** 로그인 성공 후 SPA 로 돌려보낼 주소 (Step 3에서 사용) */
|
||||||
|
private String successRedirect;
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,19 @@ spring:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
database: postgresql
|
database: postgresql
|
||||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
client:
|
||||||
|
registration:
|
||||||
|
google:
|
||||||
|
client-id: ${GOOGLE_CLIENT_ID}
|
||||||
|
client-secret: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
scope:
|
||||||
|
- openid
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
redirect-uri: "{baseUrl}/login/oauth2/code/google"
|
||||||
|
# provider 'google'은 Spring Security 내장이라 엔드포인트 설정 불필요
|
||||||
application:
|
application:
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
|
|
@ -27,6 +39,11 @@ application:
|
||||||
secret: ${OPR_SYSTEM_CLIENT_SECRET}
|
secret: ${OPR_SYSTEM_CLIENT_SECRET}
|
||||||
permissions:
|
permissions:
|
||||||
- "H:R:A"
|
- "H:R:A"
|
||||||
|
app:
|
||||||
|
oauth2:
|
||||||
|
allowed-domain: greenoilinc.com
|
||||||
|
# 로그인 성공 후 SPA로 돌려보낼 주소
|
||||||
|
success-redirect: https://portal.greenoilinc.com
|
||||||
auth:
|
auth:
|
||||||
cookie:
|
cookie:
|
||||||
secure: true # prod: true / local: false
|
secure: true # prod: true / local: false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue