diff --git a/build.gradle b/build.gradle index 264e4de..3294f6a 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // ---- OpenAPI / Swagger UI ---- implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}" diff --git a/src/main/java/com/goi/erp/auth/AuthenticationService.java b/src/main/java/com/goi/erp/auth/AuthenticationService.java index 7fd6bba..3d2bf22 100644 --- a/src/main/java/com/goi/erp/auth/AuthenticationService.java +++ b/src/main/java/com/goi/erp/auth/AuthenticationService.java @@ -42,7 +42,7 @@ public class AuthenticationService { private final AuthCookieService authCookieService; private final UserDetailsService userDetailsService; - // ===================== 로그인 ===================== + // ===================== 로그인 (비밀번호) ===================== public AuthenticationResponse authenticate( AuthenticationRequest request, @@ -55,6 +55,16 @@ public class AuthenticationService { throw new InvalidPasswordException("Invalid password"); } + // 발급 로직은 공용 메서드로 위임 (Google 로그인과 동일 경로) + return issueTokens(employee, response); + } + + // ===================== 토큰 발급 (공용) ===================== + // 비밀번호 로그인 / Google(OAuth2) 로그인 모두 이 메서드로 토큰을 발급한다. + // 호출자는 Employee 를 확보한 뒤 이 메서드를 부른다. + + public AuthenticationResponse issueTokens(Employee employee, HttpServletResponse response) { + List activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); @@ -65,11 +75,9 @@ public class AuthenticationService { String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions); revokeAllEmployeeTokens(employee); - // refresh 도 저장 saveEmployeeToken(employee, accessToken, TokenType.ACCESS); saveEmployeeToken(employee, refreshToken, TokenType.REFRESH); - // Cookie로 내려줌 authCookieService.addAuthCookie(response, accessToken); authCookieService.addRefreshCookie(response, refreshToken); @@ -117,15 +125,14 @@ public class AuthenticationService { List roles = extractRoles(activeRoles); List permissions = extractPermissions(activeRoles); - String newAccessToken = jwtService.generateToken(employee, roles, permissions); String newRefreshToken = jwtService.generateRefreshToken(employee, roles, permissions); // 기존 토큰 전부 revoke revokeAllEmployeeTokens(employee); - - // 새 토큰 저장 + + // 새 토큰 저장 saveEmployeeToken(employee, newAccessToken, TokenType.ACCESS); saveEmployeeToken(employee, newRefreshToken, TokenType.REFRESH); @@ -138,34 +145,34 @@ public class AuthenticationService { } - // ===================== System Token ===================== - // OPR / CRM 등 서버간 호출용 (Header 기반) - - public SystemAuthenticationResponseDto authenticateSystem(SystemAuthenticationRequestDto request) { - - if (request.getClientId() == null || request.getClientSecret() == null) { - throw new InvalidPasswordException("Missing client credentials"); - } - - var clientConfig = systemClientsProperties.getClients().get(request.getClientId()); - if (clientConfig == null) { - throw new InvalidPasswordException("Invalid system client"); - } - - if (!clientConfig.getSecret().equals(request.getClientSecret())) { - throw new InvalidPasswordException("Invalid system secret"); - } - - String jwtToken = jwtService.generateSystemToken( - request.getClientId(), - clientConfig.getPermissions() - ); - - return SystemAuthenticationResponseDto.builder() - .accessToken(jwtToken) - .refreshToken(null) - .build(); - } + // ===================== System Token ===================== + // OPR / CRM 등 서버간 호출용 (Header 기반) + + public SystemAuthenticationResponseDto authenticateSystem(SystemAuthenticationRequestDto request) { + + if (request.getClientId() == null || request.getClientSecret() == null) { + throw new InvalidPasswordException("Missing client credentials"); + } + + var clientConfig = systemClientsProperties.getClients().get(request.getClientId()); + if (clientConfig == null) { + throw new InvalidPasswordException("Invalid system client"); + } + + if (!clientConfig.getSecret().equals(request.getClientSecret())) { + throw new InvalidPasswordException("Invalid system secret"); + } + + String jwtToken = jwtService.generateSystemToken( + request.getClientId(), + clientConfig.getPermissions() + ); + + return SystemAuthenticationResponseDto.builder() + .accessToken(jwtToken) + .refreshToken(null) + .build(); + } // ===================== 내부 헬퍼 ===================== @@ -199,6 +206,6 @@ public class AuthenticationService { } private void revokeAllEmployeeTokens(Employee employee) { - tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId()); + tokenRepository.revokeAllValidTokensByEmployee(employee.getEmpId()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/config/SecurityConfiguration.java b/src/main/java/com/goi/erp/config/SecurityConfiguration.java index 5bbdcb5..616164d 100644 --- a/src/main/java/com/goi/erp/config/SecurityConfiguration.java +++ b/src/main/java/com/goi/erp/config/SecurityConfiguration.java @@ -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.configurers.AbstractHttpConfigurer; 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.authentication.UsernamePasswordAuthenticationFilter; 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.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_DELETE; //import static com.goi.erp.user.Permission.ADMIN_READ; @@ -45,8 +53,11 @@ import java.util.List; @EnableMethodSecurity public class SecurityConfiguration { + // NOTE: "/auth/**" 를 통째로 permitAll 하면 /auth/me 도 인증 없이 통과되어 + // (access 토큰 만료 시) 200 {authenticated:false} 를 돌려주고, 프론트의 + // 401-기반 refresh 가 트리거되지 않는다. 공개가 필요한 auth 엔드포인트만 + // 아래 authorizeHttpRequests 에서 개별적으로 permitAll 한다. private static final String[] WHITE_LIST_URL = { - "/auth/**", "/v2/api-docs", "/v3/api-docs", "/v3/api-docs/**", @@ -60,17 +71,24 @@ public class SecurityConfiguration { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; 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 public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of( - "https://portal.greenoilinc.com", - "http://localhost:5173", - "http://192.168.*.*:5173", - "http://10.*.*.*:5173" - )); + "https://portal.greenoilinc.com", + "http://localhost:5173", + "http://192.168.*.*:5173", + "http://10.*.*.*:5173" + )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-TOKEN")); config.setAllowCredentials(true); @@ -83,15 +101,24 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + // hd 파라미터를 주입하는 커스텀 인가요청 리졸버 + OAuth2AuthorizationRequestResolver authorizationRequestResolver = + new HostedDomainAuthorizationRequestResolver( + clientRegistrationRepository, + oAuth2Properties.getAllowedDomain()); + http - .cors(Customizer.withDefaults()) + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(req -> req.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() - .requestMatchers("/auth/login", "/auth/token/refresh", "/auth/login/system").permitAll() - .requestMatchers("/auth/logout").authenticated() - .requestMatchers(WHITE_LIST_URL) + .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() + .requestMatchers("/auth/login", "/auth/token/refresh", "/auth/login/system").permitAll() + // OAuth2 로그인 엔드포인트 (인가 시작 + Google 콜백) + .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll() + .requestMatchers("/auth/logout").authenticated() + .requestMatchers(WHITE_LIST_URL) .permitAll() // .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) // .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) @@ -101,6 +128,26 @@ public class SecurityConfiguration { .anyRequest() .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)) .authenticationProvider(authenticationProvider) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/goi/erp/oauth/HostedDomainAuthorizationRequestResolver.java b/src/main/java/com/goi/erp/oauth/HostedDomainAuthorizationRequestResolver.java new file mode 100644 index 0000000..ee53291 --- /dev/null +++ b/src/main/java/com/goi/erp/oauth/HostedDomainAuthorizationRequestResolver.java @@ -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 extra = new HashMap<>(req.getAdditionalParameters()); + extra.put("hd", hostedDomain); + return OAuth2AuthorizationRequest.from(req) + .additionalParameters(extra) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java b/src/main/java/com/goi/erp/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 0000000..02aa41d --- /dev/null +++ b/src/main/java/com/goi/erp/oauth/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -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 { + + 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 getCookie(HttpServletRequest request, String name) { + if (request.getCookies() == null) { + return Optional.empty(); + } + return Arrays.stream(request.getCookies()) + .filter(c -> name.equals(c.getName())) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/oauth/OAuth2LoginFailureHandler.java b/src/main/java/com/goi/erp/oauth/OAuth2LoginFailureHandler.java new file mode 100644 index 0000000..04628d1 --- /dev/null +++ b/src/main/java/com/goi/erp/oauth/OAuth2LoginFailureHandler.java @@ -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 + "\"}"); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/oauth/OAuth2LoginSuccessHandler.java b/src/main/java/com/goi/erp/oauth/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..89047e7 --- /dev/null +++ b/src/main/java/com/goi/erp/oauth/OAuth2LoginSuccessHandler.java @@ -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)); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/oauth/OAuth2Properties.java b/src/main/java/com/goi/erp/oauth/OAuth2Properties.java new file mode 100644 index 0000000..6c55330 --- /dev/null +++ b/src/main/java/com/goi/erp/oauth/OAuth2Properties.java @@ -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; +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59ccf4d..07e47ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,7 +13,19 @@ spring: format_sql: true database: postgresql 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: security: jwt: @@ -27,6 +39,11 @@ application: secret: ${OPR_SYSTEM_CLIENT_SECRET} permissions: - "H:R:A" +app: + oauth2: + allowed-domain: greenoilinc.com + # 로그인 성공 후 SPA로 돌려보낼 주소 + success-redirect: https://portal.greenoilinc.com auth: cookie: secure: true # prod: true / local: false