React - SSO 로그인을 위한 SAML 연동

brillog·2023년 8월 15일
2

Development

목록 보기
2/2

React를 활용해서 개발한 프론트엔드의 admin 페이지 접속을 위한 SAML 인증이 필요했습니다. 이를 구현하기 위해 혼자 고군분투했는데 여기저기 구글링하며 얻은 결과를 공유해 볼까 합니다. 저와 같이 SAML 연동에서 헤매고 계신 분이 있다면 도움이 되길 바랍니다 :)

SAML이란?

SAML 인증을 구현하기 위해서는 SAML이 어떻게 동작하는지 알아야 합니다. SAML은 Security Assertion Markup Language의 약자로, 인증 정보 제공자(IDP)와 서비스 제공자(SP) 간의 인증 및 인가 데이터를 교환하기 위한 XML기반의 표준 데이터 포맷을 의미합니다.

  • IdP (Identity Provider) = 인증 정보 제공자
  • SP (Service Provider) = 서비스 제공자
  • ACS (Assertion Consumer Service) = SSO 권한 확인을 위한 서비스

SAML 인증 플로우

User는 SP로 서비스를 요청하고 SP는 인증(로그인)을 위해 IdP로 요청을 보냅니다. 그러면 IdP는 로그인한 유저의 정보(ID, Email, Group 등)를 XML 형식으로 ACS에 보냅니다. 이때의 Request Method는 POST를 사용하며 POST 요청을 받은 ACSIdP가 준 유저 정보를 바탕으로 권한을 확인하여 권한을 가진 유저일 경우 유저가 접속하고자 했던 서비스로 리다이렉션 시켜 줍니다.

위와 같은 SAML 인증 플로우를 구성하기 위해 기존 서비스 Pod 외에 ACS Pod를 추가로 생성했습니다. 제가 구현한 워크플로우를 간단히 그려보면 아래와 같습니다.

구현 워크플로우

SPACS는 같은 k8s 클러스터에 Pod로 떠있으며 각각 www.service.com과 acs.service.com 도메인을 통해 접속합니다. 워크플로우는 그림으로 자세히 그려놓았기 때문에 이에 대한 설명은 생략하고 바로 코드로 가보겠습니다.

SP (Service Provider)

프론트엔드 개발에는 Javascript를 사용했으며 /admin 페이지 접속 시 권한이 있는지 확인해야 하기 때문에 axios.get('https://acs.service.com/auth/check')을 사용하여 인증 여부를 확인했습니다. 해당 부분의 코드입니다.

import React, {useEffect, useState} from 'react';
import axios from 'axios';
import { IDP_APP_URL } from '../config/constants'
...

function AdminPage() {
  ...
  const [loading, setLoading] = useState(true);
  const RedirectToLogin = () => {
    window.location.replace(IDP_APP_URL);
  }

  useEffect(
    function() {
      axios
        .get('https://acs.service.com/auth/check', { withCredentials: true })  # withCredential 사용 필수
        .then(function (response) {
          if (response.data.message === "Authorized") {
            setLoading(false);
          } else {
            RedirectToLogin();
          }
        })
        .catch(function (error) {
          console.error(error);
        });
    }, []);

  if (loading)
    return (
      <div>
        ...  // 로딩 페이지
      </div>
    );

  return (
    <div>
      ...  // Admin 페이지
    </div>
  );
}

export default AdminPage;

axios로 ACS의 /auth/check를 GET 해서 현재 접속자의 인증 여부를 확인합니다. 잠깐 ACS의 /auth/check 부분을 살펴보면, ACS는 인증이 이미 완료된 경우 {message: 'Authorized'}를 반환하고 인증이 되지 않았을 경우는 {message: 'Unauthorized'}를 반환합니다.

// ACS의 /auth/check 부분 발췌
app.get('/auth/check', (req, res, next) => {
    if (!req.isAuthenticated()) {
        res.status(200).json({message: 'Unauthorized'});
    } else {
        res.status(200).json({message: 'Authorized'});
    }
});

다시 SP의 axios.get으로 돌아와 이 부분을 자세히 살펴보면,

axios
  .get('https://acs.service.com/auth/check', { withCredentials: true })
  .then(function (response) {
    if (response.data.message === "Authorized") {
      setLoading(false);  // loading 값을 true에서 false로 변경
    } else {
      RedirectToLogin();  // IdP 로그인 화면으로 리다이렉션
    }
  })

GET 응답에서 message 값이 Authorized일 경우 loading 값을 false로 변경하고, message 값이 Unauthorized일 경우는 사용자 인증이 되지 않은 상태이기 때문에 IdP 로그인 화면으로 리다이렉션 하는 함수를 호출합니다.

ACS (Assertion Consumer Service)

ACS에서는 GET, POST 메소드 사용을 위한 express와 SAML 인증을 위한 passport를 사용합니다.

variables & function 설정

// variables
const express = require('express');
const session = require('express-session');
const cors = require('cors');
const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;
const fs = require('fs');
const bodyParser = require("body-parser");
const app = express();
const port = 8080;

const IdpCert = fs.readFileSync('./certs/IdPCertificate.cer', 'utf8');  // IdP 인증서
const AdminGroupName = 'devops-admin';  // 권한 확인을 위한 변수 선언 (devops-admin 그룹에 속한 유저만 권한 부여)

// function
function isAdminUser(groupArr, adminGroupName) {
  for (const groupName of groupArr) {
    if (groupName === adminGroupName) {
      return true;
    }
  }
}

passport 설정

passport.serializeUser(function(user, done) {
  console.log('serializing');
  console.log(user);
  done(null, user.groups);
});

passport.deserializeUser(function(user, done) {
  console.log('de-serializing');
  done(null, user);
});

passport.use(new SamlStrategy(
  {
    path: '/saml/callback',
    entryPoint: 'https://<IdP SAML WebApp Login URL>',
    issuer: 'passport-saml',
    cert: IdpCert
  },
  function(profile, done) {
    if (isAdminUser(profile.groups, AdminGroupName) === true) {
      console.log("Authentication Success");
      return done(null, profile.groups);
    } else {
      console.log("error!");
      return done(err);
    }
  })
);

// serializer와 deseriazlier는 필수로 구현해야 함
// 인증 후, 사용자 정보를 Session에 저장함
passport.serializeUser(function(user, done) {
  console.log('serialize');
  done(null, user);
});
// 인증 후, 페이지 접근시 마다 사용자 정보를 Session에서 읽어옴.
passport.deserializeUser(function(user, done) {
  console.log('deserialize');
  done(null, user);
});

express 설정

app.use(express.json());
app.use(cors({
  // CORS 설정 필수 (CORS 설정 안하면 SP의 Admin 페이지 axios.get('https://acs.service.com/auth/check') 수행 시 CORS 에러 발생)
  origin: true,
  credentials: true
}));
app.use(
  session({
    secret: 'SECRET',
    resave: true,
    saveUninitialized: true,
  }),
)
app.use(passport.initialize());
app.use(passport.session());

// Request Method
app.post(
  // IdP WepApp 설정: SAML POST 응답 시 'https://acs.service.com/saml/callback'으로 응답하도록 설정해두었음
  "/saml/callback",
  bodyParser.urlencoded({ extended: false }),
  passport.authenticate("saml", { failureRedirect: "/fail", failureFlash: true }),
  function (req, res) {
    res.redirect("https://www.service.com/admin");
  }
);

app.get('/auth/check', (req, res, next) => {
  if (!req.isAuthenticated()) {
    res.status(200).json({message: 'Unauthorized'});
  } else {
    res.status(200).json({message: 'Authorized'});
  }
});

app.get("/health", (req, res) => {
  res.send("HealthCheck Success");
});

app.get("/fail", (req, res) => {
  res.send("Centrify Authentication Failed");
});

app.listen(port, () => {
  console.log(`server.js is running... (port:${port})`);
});

Trouble shooting

브라우저 정책 상, 타 도메인의 쿠키 사용을 막고 있습니다. 프론트엔드 개발자가 아니다 보니 이 부분에서 꽤나 애를 먹었는데요ㅠ 프론트엔드에서 axios.get("ACS_DOMAIN/auth/check") 시, 인증이 완료되어 /auth/check 리턴값이 Authorized 였음에도 axios는 계속 Unauthorized로 인식하는 문제가 발생했었습니다.

이 문제를 해결하기 위해 {withCredentials: true} 옵션을 넣어주었는데요, withCredentials는 다른 도메인(Cross Origin)에 요청을 보낼 때 요청에 인증(credential) 정보를 담아서 보낼 지를 결정하는 항목이라고 합니다. 쿠키나 인증 헤더 정보를 포함시켜 요청하고 싶다면 클라이언트에서 API 요청 메소드를 보낼 때 withCredentials 옵션을 true로 설정해야 합니다.

axios.get('https://acs.service.com/auth/check', {withCredentials: true})

저도 www.service.com(SP)에서 '다른 도메인'인 acs.service.com(ACS)로 GET 요청을 해야 했기 때문에 이 옵션을 넣지 않아 /auth/check 호출 시 문제가 발생했었던 것입니다.

하지만 axios.get{withCredentials: true} 옵션을 추가하고 나니 axios.get 호출 시 브라우저에서 CORS 에러가 발생했습니다. 인증된 요청을 정상적으로 수행하기 위해서는 클라이언트(SP) 뿐만 아니라 서버(ACS)에서도 credential 설정이 필요했습니다. 이 문제는 ACS express의 CORS 설정에 credential 값을 true로 설정함으로써 해결되었습니다.

const cors = require('cors');

app.use(cors({
  origin: true,
  credentials: true
}));

결론: CORS 설정을 위해서는 클라이언트와 서버 둘 다 Credential을 true로 설정해줘야 한다.


Reference

개인적으로 공부하며 작성한 글로, 내용에 오류가 있을 수 있습니다.

profile
클라우드 엔지니어 ♡

0개의 댓글