오늘은 스프링에 Microsoft OAuth 를 연동하는 방법을 설명드리겠습니다.
보통 개인프로젝트에서는 구글, 네이버 등등을 많이 연결하지만,, 회사에서는 MS를 사용하고 있기 때문에 MS 연동을 해보았습니다.
지금 개발하고 있는 프로젝트가 Spring boot 도 아니고, Security를 사용하고 있지 않아 코드가 복잡하더라구요,,
혹시 저와 같은 상황에 있는 분들에게 도움이 되길 바라며 설명드리겠습니다.
참고로, 스프링 부트에서 스프링 시큐리티를 사용하여 MS OAuth 를 연동한 것은 여기에 설명해놓았습니다.
일단, 코드를 보여드리기 전에 OAuth 인증 프로세스부터 간단히 설명드리겠습니다.
- Microsoft가 제공하는 로그인 창이 나타납니다
- 사용자가 로그인을 완료하면 MS에서 인증 코드를 반환해줍니다.
- 인증 코드를 사용하여 MS에 Access 토큰과 Refresh 토큰을 요청합니다.
- API를 호출할 때, Access 토큰을 포함하여 웹 API를 호출합니다.
- MS 측에서 토큰 유효성 검사를 한 후, 안전한 데이터를 앱으로 반환합니다.
MS 깃허브에 MSAL 라이브러리를 사용한 예시가 나와있어 제 프로젝트에 맞는 예시를 찾아 적용시키고 프로젝트에 맞게 커스텀 하였습니다.
아래 주소에 예시코드가 나와있습니다.
개발하기 전에 앞서, 여기 정리해둔 사전작업들을 진행해주시면 되겠습니다.
하단의 코드가 MS 인증 필터입니다.
@Service(value="authFilter")
public class AuthFilter implements Filter {
@Autowired
AuthHelper authHelper;
@Autowired
private RedisService redisService;
@Autowired
private LoginFilter loginFilter;
private static List<String> urlList = null;
public AuthFilter() {
urlList = new ArrayList<String>();
// properties에서 none_filter_urls 정보를 가져온다.
Properties prop = PropertyLoader.loadProperties("/configuration/oAuthAuthentication.properties");
String url = prop.getProperty("none_filter_urls").replaceAll(" ", "");
String[] urls = url.split(",");
urlList = Arrays.asList(urls);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
this.urlList = new ArrayList<String>();
// properties에서 none_filter_urls 정보를 가져온다.
Properties prop = PropertyLoader.loadProperties("/configuration/oAuthAuthentication.properties");
String url = prop.getProperty("none_filter_urls").replaceAll(" ", "");
String[] urls = url.split(",");
this.urlList = Arrays.asList(urls);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String path = null;
if (request instanceof HttpServletRequest) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String currentUri = httpRequest.getRequestURL().toString();
path = httpRequest.getServletPath();
String queryStr = httpRequest.getQueryString();
String fullUrl = currentUri + (queryStr != null ? "?" + queryStr : "");
try {
HttpSession session = httpRequest.getSession();
//관리자 로그인 페이지, 관리자 로그인, 일반 로그인 일때 2차인증 이동
if ((!StringUtils.isEmpty(path) && path.equals("/loginFormGreennet.do"))
|| (session != null && "admin".equals(session.getAttribute("authType")))
|| (session != null && "apiLogin".equals(session.getAttribute("authType")))) {
loginFilter.doFilter(request, response, chain);
return;
}
// Single-SignOut 프로세스
String sid = getSid(httpRequest);
if (!StringUtil.isEmpty(sid)) {
//sid가 redis에 저장된(로그아웃 유저 sid) sid와 같으면 세션 삭제(로그아웃) 후 로그인 페이지로 이동
String valueForKey = redisService.getValueForKey(sid, 3);
if (!StringUtil.isEmpty(valueForKey)) {
redisService.deleteValueForKey(sid,3);
session.invalidate();
authHelper.removePrincipalFromSession(httpRequest);
}
}
// exclude home page
if(urlList.contains(path)){
chain.doFilter(request, response);
return;
}
// 인증값이 없으면
if (!AuthHelper.isAuthenticated(httpRequest)) {
//요청에 인증 코드가 있으면
if(AuthHelper.containsAuthenticationCode(httpRequest)){
processAuthenticationCodeRedirect(httpRequest, httpResponse, currentUri, fullUrl);
CookieHelper.removeStateNonceCookies(httpResponse);
} else {
// 세션이 있는지 확인하고 없으면 생성
HttpSession httpSession = httpRequest.getSession(false);
if (httpSession == null) {
httpSession = httpRequest.getSession(true);
}
// 이전에 호출했던 URL 다시 띄워주기
String preRedirectUri = getRequestUriWithParameter(httpSession, httpRequest);
httpSession.setAttribute("ikep.preRedirectUri", preRedirectUri);
sendAuthRedirect(httpRequest, httpResponse);
return;
}
}
if (isAccessTokenExpired(httpRequest)) {
authHelper.updateAuthDataUsingSilentFlow(httpRequest);
}
} catch (MsalException authException) {
// 세션지우고 로그인 페이지로 이동
authHelper.removePrincipalFromSession(httpRequest);
sendAuthRedirect(httpRequest, httpResponse);
return;
} catch (Throwable exc) {
//ms 오류나면 2차 인증 필터 이동
loginFilter.doFilter(request, response, chain);
return;
}
}
chain.doFilter(request, response);
return;
}
public String getSid(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("sid")) {
return cookie.getValue(); // 만료 안됨
}
}
}
return null;
}
// Base64 URL 인코딩된 문자열의 패딩을 추가하는 메서드
private static String padBase64(String base64) {
int mod = base64.length() % 4;
if (mod > 0) {
base64 += "====".substring(mod);
}
return base64;
}
//jwt에서 sid 가져오기
public static String getSidFromJwt(String jwt) throws Exception {
String[] parts = jwt.split("\\.");
if (parts.length < 2) {
throw new IllegalArgumentException("Invalid JWT token format.");
}
String payload = new String(Base64.getUrlDecoder().decode(padBase64(parts[1])), StandardCharsets.UTF_8);
org.json.JSONObject payloadJson;
try {
payloadJson = new org.json.JSONObject(payload);
} catch (Exception e) {
throw new Exception("Error parsing JSON from payload: " + e.getMessage(), e);
}
// SID 값 추출
String sid = payloadJson.optString("sid", null);
if (sid == null) {
throw new Exception("SID not found in the payload.");
}
return sid;
}
private boolean isAccessTokenExpired(HttpServletRequest httpRequest) {
IAuthenticationResult result = AuthHelper.getAuthSessionObject(httpRequest);
return result.expiresOnDate().before(new Date());
}
private void processAuthenticationCodeRedirect(HttpServletRequest httpRequest,HttpServletResponse httpResponse, String currentUri, String fullUrl)
throws Throwable {
Map<String, List<String>> params = new HashMap<>();
for (String key : httpRequest.getParameterMap().keySet()) {
params.put(key, Collections.singletonList(httpRequest.getParameterMap().get(key)[0]));
}
// validate that state in response equals to state in request
//validateState(CookieHelper.getCookie(httpRequest, MSAL_WEB_APP_STATE_COOKIE), params.get(STATE).get(0));
AuthenticationResponse authResponse = AuthenticationResponseParser.parse(new URI(fullUrl), params);
if (AuthHelper.isAuthenticationSuccessful(authResponse)) {
AuthenticationSuccessResponse oidcResponse = (AuthenticationSuccessResponse) authResponse;
// validate that OIDC Auth Response matches Code Flow (contains only requested artifacts)
validateAuthRespMatchesAuthCodeFlow(oidcResponse);
IAuthenticationResult result = authHelper.getAuthResultByAuthCode(
httpRequest,
oidcResponse.getAuthorizationCode(),
currentUri);
// validate nonce to prevent reply attacks (code maybe substituted to one with broader access)
//validateNonce(CookieHelper.getCookie(httpRequest, MSAL_WEB_APP_NONCE_COOKIE), getNonceClaimValueFromIdToken(result.idToken()));
authHelper.setSessionPrincipal(httpRequest, result);
//sid 쿠키 저장
Cookie cookie = new Cookie("sid", getSidFromJwt(result.idToken()));
cookie.setPath("/");
cookie.setMaxAge(24 * 60 * 60); // 쿠키 유효 기간을 하루로 설정
httpResponse.addCookie(cookie);
} else {
AuthenticationErrorResponse oidcResponse = (AuthenticationErrorResponse) authResponse;
throw new Exception(String.format("Request for auth code failed: %s - %s",
oidcResponse.getErrorObject().getCode(),
oidcResponse.getErrorObject().getDescription()));
}
}
private void sendAuthRedirect(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException {
// state parameter to validate response from Authorization server and nonce parameter to v0alidate idToken
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
CookieHelper.setStateNonceCookies(httpRequest, httpResponse, state, nonce);
httpResponse.setStatus(302);
String redirectUrl = getRedirectUrl(httpRequest, httpRequest.getParameter("claims"), state, nonce);
httpResponse.sendRedirect(redirectUrl);
}
private String getNonceClaimValueFromIdToken(String idToken) throws ParseException {
return (String) JWTParser.parse(idToken).getJWTClaimsSet().getClaim("nonce");
}
private void validateState(String cookieValue, String state) throws Exception {
if (StringUtils.isEmpty(state) || !state.equals(cookieValue)) {
throw new Exception(FAILED_TO_VALIDATE_MESSAGE + "could not validate state");
}
}
private void validateNonce(String cookieValue, String nonce) throws Exception {
if (StringUtils.isEmpty(nonce) || !nonce.equals(cookieValue)) {
throw new Exception(FAILED_TO_VALIDATE_MESSAGE + "could not validate nonce");
}
}
private void validateAuthRespMatchesAuthCodeFlow(AuthenticationSuccessResponse oidcResponse) throws Exception {
if (oidcResponse.getIDToken() != null || oidcResponse.getAccessToken() != null ||
oidcResponse.getAuthorizationCode() == null) {
throw new Exception(FAILED_TO_VALIDATE_MESSAGE + "unexpected set of artifacts received");
}
}
private String getRedirectUrl(HttpServletRequest request, String claims, String state, String nonce)
throws UnsupportedEncodingException {
String redirectUrl = authHelper.getAuthority() + "oauth2/v2.0/authorize?" +
"response_type=code&" +
"response_mode=form_post&" +
"redirect_uri=" + URLEncoder.encode(authHelper.getRedirectUri(), "UTF-8") +
"&client_id=" + authHelper.getClientId() +
"&scope=" + URLEncoder.encode("openid offline_access profile", "UTF-8") +
(StringUtils.isEmpty(claims) ? "" : "&claims=" + claims) +
"&state=" + state
+ "&nonce=" + nonce;
return redirectUrl;
}
}
저는 1차 로그인은 MS로 제공하되, MS 오류 시 기존에 제공하던 로그인 페이지로 넘어가는 방식을 제공하였습니다.
그렇게 때문에, 오류가 나면 2차 로그인 필터로(LoginFilter) 넘어가는 소스를 작성해주었습니다.
로그아웃 같은 경우에는, 같은 브라우저의 타 ms 제품군에서 로그아웃을 하면 제가 맡은 시스템도 자동 MS 로그아웃이 되어야합니다.
Single sing-out 이 작동되려면 Microsoft Azure 사이트에서 프런트 채널 로그아웃 URL 이란 것을 등록해야합니다.
이렇게 타 MS 제품군에서 로그아웃을 하면 호출될 URL 을 작성해주고, 해당 URL 이 호출될 때, 로그아웃 프로세스를 진행하게 코드를 짜줍니다.
저는 Redis 에 어떤 사용자가 로그아웃을 하였는지 정보를 저장해준 다음, 방금 보여드렸던 필터에서 유저정보가 같으면 레디스 정보를 삭제해주고 로그아웃을 하는 프로세스를 구현하였습니다. (바로 MS 로그아웃 URL 을 호출하여도 되지만, 제가 맡고있는 프로젝트에서는 자체 세션도 있고 2차 로그인도 있기때문에 좀 더 복잡한 방식을 택했습니다. 해당 방법은, 본인의 프로젝트 상황에 맞게 진행해주시면 되겠습니다.)
@RequestMapping(value = "/auth/oauthLogout.do")
public void oauthLogout(HttpServletRequest req, HttpServletResponse response) {
// Redis 데이터 저장
redisService.insertValueForKey(req.getParameter("sid"), "logout",3);
}
@RequestMapping(value = "/oauthLogout.do")
public String oauthLogoutForm(HttpSession session, HttpServletRequest httpRequest, HttpServletResponse response) {
httpRequest.getSession().invalidate();
String endSessionEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/logout";
try {
response.sendRedirect(endSessionEndpoint);
return null; // sendRedirect() 후 null 반환으로 종료
} catch (IOException e) {
return null;
//return "redirect:/loginForm.do"; // 예외 발생 시 loginForm.do로 리다이렉트
}
}
// Single-SignOut 프로세스
String sid = getSid(httpRequest);
if (!StringUtil.isEmpty(sid)) {
//sid가 redis에 저장된(로그아웃 유저 sid) sid와 같으면 세션 삭제(로그아웃) 후 로그인 페이지로 이동
String valueForKey = redisService.getValueForKey(sid, 3);
if (!StringUtil.isEmpty(valueForKey)) {
redisService.deleteValueForKey(sid,3);
session.invalidate();
authHelper.removePrincipalFromSession(httpRequest);
}
}
아래의 코드는 AuthHelper 코드입니다. 해당 코드는 MS 예시에 나와있는 코드 그대로 사용해주었습니다.
@Component
class AuthHelper {
static final String PRINCIPAL_SESSION_NAME = "test";
static final String TOKEN_CACHE_SESSION_ATTRIBUTE = "test";
static final String END_SESSION_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/logout";
private String clientId;
private String clientSecret;
private String authority;
private String redirectUri;
@Autowired
BasicConfiguration configuration;
@PostConstruct
public void init() {
clientId = configuration.getClientId();
authority = configuration.getAuthority();
clientSecret = configuration.getSecretKey();
redirectUri = configuration.getRedirectUri();
}
private ConfidentialClientApplication createClientApplication() throws MalformedURLException {
return ConfidentialClientApplication.builder(clientId, ClientCredentialFactory.createFromSecret(clientSecret)).
authority(authority).
build();
}
IAuthenticationResult getAuthResultBySilentFlow(HttpServletRequest httpRequest, String scope) throws Throwable {
IAuthenticationResult result = AuthHelper.getAuthSessionObject(httpRequest);
IAuthenticationResult updatedResult;
ConfidentialClientApplication app;
try {
app = createClientApplication();
Object tokenCache = httpRequest.getSession().getAttribute("token_cache");
if(tokenCache != null){
app.tokenCache().deserialize(tokenCache.toString());
}
SilentParameters parameters = SilentParameters.builder(
Collections.singleton(scope),
result.account()).build();
CompletableFuture<IAuthenticationResult> future = app.acquireTokenSilently(parameters);
updatedResult = future.get();
} catch (ExecutionException e) {
throw e.getCause();
}
//update session with latest token cache
storeTokenCacheInSession(httpRequest, app.tokenCache().serialize());
return updatedResult;
}
IAuthenticationResult getAuthResultByAuthCode(
HttpServletRequest httpServletRequest,
AuthorizationCode authorizationCode,
String currentUri) throws Throwable {
IAuthenticationResult result;
ConfidentialClientApplication app;
try {
app = createClientApplication();
String authCode = authorizationCode.getValue();
AuthorizationCodeParameters parameters = AuthorizationCodeParameters.builder(
authCode,
new URI(currentUri)).
build();
Future<IAuthenticationResult> future = app.acquireToken(parameters);
result = future.get();
} catch (ExecutionException e) {
throw e.getCause();
}
if (result == null) {
throw new ServiceUnavailableException("authentication result was null");
}
storeTokenCacheInSession(httpServletRequest, app.tokenCache().serialize());
return result;
}
private void storeTokenCacheInSession(HttpServletRequest httpServletRequest, String tokenCache){
httpServletRequest.getSession().setAttribute(AuthHelper.TOKEN_CACHE_SESSION_ATTRIBUTE, tokenCache);
}
void setSessionPrincipal(HttpServletRequest httpRequest, IAuthenticationResult result) {
httpRequest.getSession().setAttribute(AuthHelper.PRINCIPAL_SESSION_NAME, result);
}
void removePrincipalFromSession(HttpServletRequest httpRequest) {
httpRequest.getSession().removeAttribute(AuthHelper.PRINCIPAL_SESSION_NAME);
}
void updateAuthDataUsingSilentFlow(HttpServletRequest httpRequest) throws Throwable {
IAuthenticationResult authResult = getAuthResultBySilentFlow(httpRequest, "https://graph.microsoft.com/.default");
setSessionPrincipal(httpRequest, authResult);
}
static boolean isAuthenticationSuccessful(AuthenticationResponse authResponse) {
return authResponse instanceof AuthenticationSuccessResponse;
}
static boolean isAuthenticated(HttpServletRequest request) {
return request.getSession().getAttribute(PRINCIPAL_SESSION_NAME) != null;
}
static IAuthenticationResult getAuthSessionObject(HttpServletRequest request) {
Object principalSession = request.getSession().getAttribute(PRINCIPAL_SESSION_NAME);
if(principalSession instanceof IAuthenticationResult){
return (IAuthenticationResult) principalSession;
} else {
throw new IllegalStateException();
}
}
static boolean containsAuthenticationCode(HttpServletRequest httpRequest) {
Map<String, String[]> httpParameters = httpRequest.getParameterMap();
boolean isPostRequest = httpRequest.getMethod().equalsIgnoreCase("POST");
boolean containsErrorData = httpParameters.containsKey("error");
boolean containIdToken = httpParameters.containsKey("id_token");
boolean containsCode = httpParameters.containsKey("code");
return isPostRequest && containsErrorData || containsCode || containIdToken;
}
public static String getPrincipalSessionName() {
return PRINCIPAL_SESSION_NAME;
}
public static String getTokenCacheSessionAttribute() {
return TOKEN_CACHE_SESSION_ATTRIBUTE;
}
public static String getEndSessionEndpoint() {
return END_SESSION_ENDPOINT;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getClientSecret() {
return clientSecret;
}
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
public String getAuthority() {
return authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
public String getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(String redirectUri) {
this.redirectUri = redirectUri;
}
public BasicConfiguration getConfiguration() {
return configuration;
}
public void setConfiguration(BasicConfiguration configuration) {
this.configuration = configuration;
}
}
나머지 파일들도 위와 동일하게 MS 깃허브에서 복사해서 사용해주었기 때문에, 깃허브에서 코드 복사해서 진행해주시면 되겠습니다.
'WEB' 카테고리의 다른 글
[WEB] 스프링 프로젝트에 Microsoft OAuth 연동하기_사전 작업 (1) | 2025.01.22 |
---|---|
[Spring Boot] 스프링 시큐리티에 Microsoft OAuth 연동하기 (1) | 2025.01.15 |
[코드 리팩토링] Compose 메소드 패턴 (0) | 2024.11.20 |
[Spring boot] Spring Security에 구글 로그인 연동하기 (2) | 2024.11.13 |
[Spring Security] Session 기반 인증 방식 VS Token 기반 인증 방식 (1) | 2024.11.08 |