오늘은 스프링 부트에 `스프링 시큐리티`를 사용하여 `MS Oauth`를 연동해보겠습니다.
참고로, 스프링에 MS Oauth 를 적용한 코드는 추후 작성 예정입니다!
ms 공식 문서에 가면 oauth 적용 방법에 대해서 자세히 나와있습니다.
하지만 해당 사이트에서 스프링 시큐리티 6.X 이전 버전만 존재하여 해당 버전을 쓰신다면 위 문서 참고하시면 되겠습니다.
저는 스프링 시큐리티 6.X 버전을 사용하였습니다.
로그인 프로세스는 `1차 로그인`으로 `MS 인증`을 도입하였고, MS 인증 실패 시 `자체 로그인 API` 로 이동 할 수 있게 하였습니다.
개발하기 전에 앞서, 여기 정리해둔 사전작업들을 진행해주시면 되겠습니다.
먼저, `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:/";
}
}
개선할 부분이 있으면 댓글로 남겨주세요.
'WEB' 카테고리의 다른 글
[WEB] 스프링 프로젝트에 Microsoft OAuth 연동하기_사전 작업 (1) | 2025.01.22 |
---|---|
[Spring] 스프링 MVC에 Microsoft OAuth 연동하기 (2) | 2025.01.22 |
[코드 리팩토링] Compose 메소드 패턴 (0) | 2024.11.20 |
[Spring boot] Spring Security에 구글 로그인 연동하기 (2) | 2024.11.13 |
[Spring Security] Session 기반 인증 방식 VS Token 기반 인증 방식 (1) | 2024.11.08 |