본문 바로가기

WEB

[Spring Boot] 스프링 시큐리티에 Microsoft OAuth 연동하기

오늘은 스프링 부트에 `스프링 시큐리티`를 사용하여 `MS Oauth`를 연동해보겠습니다.

참고로, 스프링에 MS Oauth 를 적용한 코드는 추후 작성 예정입니다!

 

 

ms 공식 문서에 가면 oauth 적용 방법에 대해서 자세히 나와있습니다.

https://learn.microsoft.com/ko-kr/azure/developer/java/spring-framework/spring-security-support?tabs=SpringCloudAzure5x

 

Spring Cloud Azure Spring Security 지원 - Java on Azure

이 문서에서는 Spring Cloud Azure와 Spring Security를 함께 사용하는 방법을 설명합니다.

learn.microsoft.com

하지만 해당 사이트에서 스프링 시큐리티 6.X 이전 버전만 존재하여 해당 버전을 쓰신다면 위 문서 참고하시면 되겠습니다.

저는 스프링 시큐리티 6.X 버전을 사용하였습니다.

 

로그인 프로세스는 `1차 로그인`으로 `MS 인증`을 도입하였고, MS 인증 실패 시 `자체 로그인 API` 로 이동 할 수 있게 하였습니다.

 


개발하기 전에 앞서, 여기 정리해둔 사전작업들을 진행해주시면 되겠습니다.

https://e-you.tistory.com/459

 

[WEB] 스프링 프로젝트에 Microsoft OAuth 연동하기_사전 작업

안녕하세요! 오늘은 Microsoft OAuth 연동하는 방법을 알려 드리려고 합니다.저는 스프링부트, 스프링에 MS 를 연동하였고 코드는 아래의 페이지에 설명해두었습니다. https://e-you.tistory.com/458 [Spring]

e-you.tistory.com


먼저, `gradle`에 다음과 같은 의존성을 추가해줍니다. 해당 의존성을 추가하면 MS 라이브러리를 사용할 수 있게 됩니다.

// ms oauth
    implementation group: 'com.microsoft.azure', name: 'msal4j', version: '1.18.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-oauth2-client', version: '3.4.1'
    implementation group: 'com.azure.spring', name: 'spring-cloud-azure-starter-active-directory', version: '5.19.0'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-oauth2-resource-server', version: '3.4.1'

 

 

 

다음으로, yml 파일에 MS 설정 정보를 등록해줍니다.

Microsoft Entra 관리센터에서 서비스의 tenant-id, client-id, client-secret을 확인하여 넣어주시면 됩니다.

  spring:
      cloud:
        azure:
          active-directory:
            enabled: true
            profile:
              tenant-id: {tenant-id}
            credential:
              client-id:  {client-id}
              client-secret: {client-secret}
            redirect-uri: http://localhost:8080/login/oauth2/code/azure
          provider:
            azure:
              authorization-uri: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize

 

 

 

이제 MS Oauth 를 적용할 수 있는 환경이 되었습니다.

`SecurityConfig` 파일에서 아래와 같은 코드를 적용해줍니다.

 하단의 코드를 등록하면 서비스 시작 시 Oauth 로그인 페이지가 자동으로 띄워지게 됩니다.

http.with(AadResourceServerHttpSecurityConfigurer.aadResourceServer(), Customizer.withDefaults())
            .oauth2Login(oauth2 -> oauth2
                    .successHandler(MsSuccessHandler) // 성공 핸들러 등록
                    .failureHandler((request, response, exception) -> {
                        response.sendRedirect("/loginForm");
                    }) // 실패하면 2차 로그인 페이지로 이동
                    .permitAll()
            )

 

MS Oauth 인증이 성공하면 MsSuccessHandler로 이동하고, 실패하면 기구축된 2차 로그인 API를 사용하는 로그인 페이지로 넘겨주었습니다. 핸들러와 URL 중에 본인에게 맞는 사용법을 적용해주시면 되겠습니다.

 

 

 

@Component
public class MsSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        try {
            // 인증 성공 후 로직 작성
            System.out.println("OAuth2 인증 성공!");
            System.out.println("인증 정보: " + authentication.getPrincipal());

            // 필요한 비즈니스 로직 수행 (예: 사용자 정보 저장, 세션 처리 등)
            if (authentication.getPrincipal() instanceof DefaultOidcUser) {
                DefaultOidcUser oidcUser = (DefaultOidcUser) authentication.getPrincipal();
                String email = oidcUser.getEmail();
                String name = oidcUser.getFullName();
                System.out.println("사용자 이메일: " + email);
                System.out.println("사용자 이름: " + name);
            }

            response.sendRedirect("/oauthLogin");

        } catch (Exception ex) {
            // 예외 발생 시 실패 핸들러 호출
            response.sendRedirect("/login");
        }
    }
}

위 로직이 MS 인증 성공 시 호출되는 메소드입니다. 해당 부분을 핸들러로 만들어주었습니다. 

 

마지막에 Redirect를 사용하지 않고 해당 메소드에서 끝내도 되지만,

저는 사내 DB에 저장된 사용자 정보를 가지고 오는 과정을 추가하고 해당 정보를 가져올 때 에러가 발생하면 2차 로그인 페이지로 이동해줄 것이기 때문에 Redirect를 사용해주었습니다.

 

만약, 2차 로그인을 사용하지 않고 MS Oauth 만 적용할 것이면 여기에서 끝내주시면 됩니다.

 

 

 

@GetMapping("/oauthLogin")
    public String oauthLogin(HttpServletRequest request) throws Exception {
        System.out.println("oauth 로그인 성공");

        Authentication authentication = SecurityContextHolder.getContext()
                                                             .getAuthentication();
       
        try {
            // 여기서 DB 연결해서 사용자 정보 들고오기
            
            
        } catch (Exception e) {
            // MS 인증 정보 삭제 후 2차 로그인 페이지로 이동
            SecurityContextHolder.clearContext();  // 인증 정보 초기화
            request.getSession().invalidate();  // 세션 무효화
            return "redirect:/loginForm";
        }

    }

Redirect 로 이동해준 컨트롤러에서 DB 에 연결하여 사용자 정보를 불러오고, 해당 과정에서 오류가 난다면 2차 로그인 페이지로 이동해줍니다.

 

 

    @GetMapping("/loginForm")
    public String loginForm() {
        return "login/login";
    }

해당 메소드가 2차 로그인 페이지로 보내는 컨트롤러 입니다.

 

 

 

@Slf4j
public class ApiAuthFilter extends UsernamePasswordAuthenticationFilter {

    private final String loginApiUrl = loginApiUrl;
    private final String appKey = appKey;

    public ApiAuthFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        setFilterProcessesUrl("/auth"); // 로그인 경로
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        // 요청에서 ID와 비밀번호 추출
        String userId = request.getParameter("userId");
        String password = request.getParameter("password");

        if (userId == null || password == null) {
            throw new AuthenticationServiceException("아이디와 비밀번호는 필수입니다.");
        }

        // 로그인 API 호출
        boolean isAuthenticated = authenticate(userId, password);

        if (!isAuthenticated) {
            throw new AuthenticationException("Invalid userId or password") {};
        }

        // 인증 토큰 생성
        return new UsernamePasswordAuthenticationToken(userId, password, null);
    }

    private boolean authenticate(String userId, String password) {
        // API 연결
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            HttpPost httpPost = new HttpPost(loginApiUrl);
            httpPost.setHeader("x-api-key", appKey);
            httpPost.setHeader("Content-Type", "application/json");

            ObjectMapper objectMapper = new ObjectMapper();
            String jsonBody = objectMapper.writeValueAsString(Map.of("id", userId, "password", password));
            StringEntity entity = new StringEntity(jsonBody, "UTF-8");
            httpPost.setEntity(entity);

            try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
                int statusCode = response.getStatusLine().getStatusCode();

                if (statusCode == HttpURLConnection.HTTP_OK) {
                    // 로그인 성공 시 외부접속체크, 본인인증 진행
                    return true;
                } else {
                    // 로그인 실패
                    String responseBody = EntityUtils.toString(response.getEntity(), "UTF-8");

                    ObjectMapper mapper = new ObjectMapper();
                    JsonNode node = mapper.readTree(responseBody);

                    String errorMessage = node.path("errorMessage")
                                              .path("type")
                                              .asText();
                    return false;
                }
            }
        } catch (Exception e) {

        }
        // 로그 쌓기
        log.info("Calling external login API: {}", loginApiUrl);

        return true;
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        log.info("로그인 성공 - 사용자: {}", authResult.getName());

        // 인증 정보를 SecurityContextHolder에 저장
        SecurityContextHolder.getContext().setAuthentication(authResult);

        // 세션과 SecurityContext 연계
        HttpSession session = request.getSession(true);
        session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());

        response.sendRedirect("/"); // 성공 시 메인 페이지로 리디렉트
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.error("로그인 실패 - 이유: {}", failed.getMessage());
        response.sendRedirect("/login?error=true"); // 실패 시 로그인 페이지로 리디렉트
    }

}

스프링 시큐리티는 UsernamePasswordAuthenticationFilter 를 호출하며 로그인을 진행합니다.

저는 로그인 할 때, 외부 API 와 연동해주기 위해 UsernamePasswordAuthenticationFilter를 상속받아 커스텀 필터를 만들어 주었습니다.

 

 

.addFilterBefore(apiAuthFilter, UsernamePasswordAuthenticationFilter.class);

커스텀 한 필터를 SecurityConfig 파일에 등록해줍니다. 이렇게하면 2차 로그인 기능을 사용하실 수 있습니다.

 

 


이제 로그아웃을 구현해봅시다.

.logout(logout -> logout.logoutUrl("/logout") // 로그아웃 URL 설정
                                    .addLogoutHandler(new MsLogoutHandler()))

 

SecurityConfig 에 로그아웃 URL 과 핸들러를 설정해줍니다.

 

@Component
public class MsLogoutHandler implements LogoutHandler {

    @Value("${spring.cloud.azure.active-directory.profile.tenant-id}")
    private String tenantId;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        String postLogoutRedirectUri = "http://localhost:8080"; // 로그아웃 후 리디렉션할 URL

        String logoutUrl = "https://login.microsoftonline.com/" + tenantId +
                "/oauth2/v2.0/logout?post_logout_redirect_uri=" + postLogoutRedirectUri;

        try {
            // MS 인증 정보 삭제 후 세션 무효화
            SecurityContextHolder.clearContext();
            request.getSession()
                   .invalidate();

            response.sendRedirect(logoutUrl);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

먼저, 로그아웃 후 리디렉션할 URL 을 설정해줍니다.

그 다음, MS 인증 정보와 세션을 초기화 시켜주고 MS Logout 호출을 해줍니다.

 

 


SecurityConfig 파일

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final MsSuccessHandler msSuccessHandler;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authManager) throws Exception {
        ApiAuthFilter apiAuthFilter = new ApiAuthFilter(authManager);

        http.authorizeHttpRequests(auth -> auth.requestMatchers("/login", "/error")
                                               .permitAll() // 로그인 화면 및 에러 페이지는 모두 접근 가능
                                               .anyRequest() // 나머지 요청은
                                               .authenticated() // 인증 필요
            )
            .with(AadResourceServerHttpSecurityConfigurer.aadResourceServer(), Customizer.withDefaults())
            .oauth2Login(oauth2 -> oauth2.loginPage("/oauth2/authorization/azure")
                                         .successHandler(msSuccessHandler) // 성공 핸들러 등록
                                         .failureHandler((request, response, exception) -> response.sendRedirect("/login"))
                                         .permitAll())
            .logout(logout -> logout.logoutUrl("/logout") // 로그아웃 URL 설정
                                    .addLogoutHandler(new MsLogoutHandler()))
            .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화
            .addFilterBefore(apiAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

}

 

 

AuthController

@Controller
@RequiredArgsConstructor
public class AuthController {

    /**
     * API 로그인 후, 메인 페이지로 이동
     * */
    @GetMapping("/login")
    public String login() {
        // 현재 사용자 인증 상태 확인
        Authentication authentication = SecurityContextHolder.getContext()
                                                             .getAuthentication();
        // 인증된 경우 메인 페이지로 리다이렉트
        if (authentication != null && authentication.isAuthenticated() &&
                !(authentication instanceof AnonymousAuthenticationToken)) {
            return "redirect:/";
        }

        return "login/login";
    }

    /**
     * MS 로그인 후, 메인 페이지로 이동
     * */
    @GetMapping("/oauthLogin")
    public String oauthLogin(HttpServletRequest request) throws Exception {
        System.out.println("oauth 로그인 성공");

        Authentication authentication = SecurityContextHolder.getContext()
                                                             .getAuthentication();

        try {
            // TODO : 여기서 그린넷 DB 연결해서 사용자 정보 다 들고오기

        } catch (Exception e) {
            // MS 인증 정보 삭제 후 2차 로그인 페이지로 이동
            SecurityContextHolder.clearContext();
            request.getSession()
                   .invalidate();
            return "redirect:/login";
        }

        return "redirect:/";
    }

}

 

 

 

 

 

개선할 부분이 있으면 댓글로 남겨주세요.