본문 바로가기

WEB

[Spring] 스프링 MVC에 Microsoft OAuth 연동하기

오늘은 스프링에 Microsoft OAuth 를 연동하는 방법을 설명드리겠습니다.

 

보통 개인프로젝트에서는 구글, 네이버 등등을 많이 연결하지만,, 회사에서는 MS를 사용하고 있기 때문에 MS 연동을 해보았습니다.

지금 개발하고 있는 프로젝트가 Spring boot 도 아니고, Security를 사용하고 있지 않아 코드가 복잡하더라구요,,

혹시 저와 같은 상황에 있는 분들에게 도움이 되길 바라며 설명드리겠습니다.

 

참고로, 스프링 부트에서 스프링 시큐리티를 사용하여 MS OAuth 를 연동한 것은 여기에 설명해놓았습니다.

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

 

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

오늘은 스프링 부트에 `스프링 시큐리티`를 사용하여 `MS Oauth`를 연동해보겠습니다.참고로, 스프링에 MS Oauth 를 적용한 코드는 추후 작성 예정입니다!  ms 공식 문서에 가면 oauth 적용 방법에 대

e-you.tistory.com

 

 


일단, 코드를 보여드리기 전에 OAuth 인증 프로세스부터 간단히 설명드리겠습니다.

 

  1. Microsoft가 제공하는 로그인 창이 나타납니다
  2. 사용자가 로그인을 완료하면 MS에서 인증 코드를 반환해줍니다.
  3. 인증 코드를 사용하여 MS에 Access 토큰과 Refresh 토큰을 요청합니다.
  4. API를 호출할 때, Access 토큰을 포함하여 웹 API를 호출합니다.
  5. MS 측에서 토큰 유효성 검사를 한 후, 안전한 데이터를 앱으로 반환합니다.

 

MS 깃허브에 MSAL 라이브러리를 사용한 예시가 나와있어 제 프로젝트에 맞는 예시를 찾아 적용시키고 프로젝트에 맞게 커스텀 하였습니다.

 

아래 주소에 예시코드가 나와있습니다.

https://github.com/Azure-Samples/ms-identity-msal-java-samples/tree/main/1-server-side/msal-java-webapp-sample/src/main/java/com/microsoft/azure/msalwebsample

 

ms-identity-msal-java-samples/1-server-side/msal-java-webapp-sample/src/main/java/com/microsoft/azure/msalwebsample at main · A

Contribute to Azure-Samples/ms-identity-msal-java-samples development by creating an account on GitHub.

github.com


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

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

 

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

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

e-you.tistory.com


하단의 코드가 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 깃허브에서 복사해서 사용해주었기 때문에, 깃허브에서 코드 복사해서 진행해주시면 되겠습니다.