eGovframework 세션 로그인 분석 / 통합 SSO 로그인 연동 기능 개발

eGovframework 세션 로그인 분석

jsp에서 로그인 요청

유저가 입력한 아이디, 비밀번호가 담긴 Form을 파라미터로 ajax 호출하여 서버에 요청합니다.
‘아이디 저장’ 체크박스 값이 checked이면 쿠키에 아이디를 30일 간 저장합니다.

서버에서 세션 로그인 처리

SSO 로그인 API 호출
Controller에서 ajax 요청을 받고, LoginAPIUtil의 login 함수를 호출합니다.
SSO 로그인 API URL에 get 방식으로 아이디, 비밀번호를 담고 HttpURLConnection을 통해 요청합니다.
json 형태로 받은 결과 값을 BufferedReader로 읽고 JSONParser로 파싱해서 리턴합니다.
Controller에서 LoginAPIUtil 리턴 값으로 정상 로그인 체크 후 공통 함수를 호출합니다.

세션 로그인 처리
공통 함수에서 유저 테이블에서 유저 아이디로 유저 고유 ID를 조회합니다.
조회 결과가 없으면 유저 고유 ID와 암호화 패스워드를 생성하고, 유저 테이블과 유저 권한 테이블(COMTNEMPLYRSCRTYESTBS)에 각각 insert 합니다.
마지막으로, 유저 테이블에서 유저 고유 ID로 유저 정보들을 조회하여 LoginVO에 담아 리턴합니다.

Controller에서 LoginVO를 받아 세션에 저장합니다.

req.getSession().setAttribute("loginVO", loginVO);

ModelAndView에 유저 아이디, 유저 token, 로그인 여부를 담아 JsonView로 리턴합니다.

jsp 콜백 함수에서 후처리

로그인 성공 시
쿠키를 생성하고 로그인 처리하는 SSO URL에 get 방식으로 사이트명과 유저 토큰 값을 담습니다.
IE11이면 SSO URL을 script 태그의 src 값으로 설정한 뒤 head에 append 합니다.
IE11이 아니면 SSO URL을 Jquery getScript 함수를 통해 실행한 뒤 해당 유저 메인으로 URL을 이동합니다.

로그인 실패 시
‘아이디/비밀번호를 다시 입력해 주세요.’ 팝업을 띄웁니다.


기존 SSO 로그인 유지 처리 분석

Interceptior에서 각 화면 로그인 여부 체크

HandlerInterceptorAdapter를 상속해서 구현한 Interceptor의 preHandle 함수에서 로그인 여부와 권한을 체크하고 데이터를 담아줍니다.

egov-com-interceptor.xml 설정 예시

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:p="http://www.springframework.org/schema/p"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd
        http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.0.xsd">
    <beans profile="session">
	    <mvc:interceptors>
	        <mvc:interceptor>
	            <mvc:mapping path="/cmmn/**" />
	            <mvc:mapping path="/user/**" />
	            <mvc:mapping path="/uat/uia/actionLogin**" />
	            <mvc:exclude-mapping path="/cmm/fms/FileDown.do**" /> <!-- 파일다운로드 -->
	            <mvc:exclude-mapping path="/cmm/fms/guideDown/**" /> <!-- 파일다운로드 -->
	            <mvc:exclude-mapping path="/im/**" />
	            <bean class="com.intermorph.cmmn.interceptor.AuthenticUserInterceptor">
                <property name="adminAuthPatternList">
                  <list>
                    <value>/user/common/file/**</value>
                    <value>/cmmn/common/FileDown.do**</value>
                    <value>/cmmn/common/FileDownEnc.do**</value>
                    <value>/user/common/file/**</value>
                    <value>/cmmn/cmmnFile/**</value>
                    <value>/cmmn/chek/**</value>
                    <value>/cmmn/cnts/**</value>
                    <value>/cmmn/ussMngr/overchek.do**</value>
                    <value>/user/study/cnts/**</value>
                    <value>/cmmn/study/cnts/**</value>
                    <value>/user/srvyp/selectListQstnPreview.do**</value>
                    <value>/user/tstp/preview.do**</value>
                  </list>
                </property>
                <property name="fullLayoutPatternList">
                  <list>
                    <value>/cmmn/login.do**</value>
                    <value>/cmmn/ussMngr/regist**</value>
                    <value>/cmmn/ussMngr/find**</value>
                    <value>/uat/uia/actionLogin**</value>
                  </list>
                </property>
	            </bean>
	        </mvc:interceptor>
	    </mvc:interceptors> 
	</beans>
</beans>

mvc:mapping : 인터셉터가 적용될 URL 패턴 설정
mvc:exclude-mapping : 인터셉터를 적용하지 않을 특정 URL 패턴 설정
property : 인터셉터에서 private 변수에 저장하고 사용할 속성

Interceptor에서 로그인 여부 체크 예시

boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();

if(isAuthenticated) {
  // 인증된 사용자 : 권한 체크 후 세션에 저장했던 LoginVO를 반환 받아 request에 담아줍니다.
  LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser();
  request.setAttribute("LoginUser", user);
} else {
  // 미인증 사용자 : request에 세션ID를 암호화하여 담고 ModelAndView를 통해 로그인 화면 URL으로 redirect 합니다.
  // 세션ID는 7CF30592B6FFCB7A4070A7F3BB598C19 같은 형태이며, 같은 브라우저 내에서는 다른 탭에서도 같은 세션ID를 가집니다.
  // _sessid는 현재 세션 ID에 특정 문자열과 난수를 더해서 AES256 암호화한 값입니다.
  request.setAttribute("_sessid", LoginAPIUtil.authorizeKey(request.getSession().getId()));
  ModelAndView modelAndView = new ModelAndView("redirect:/로그인페이지.do");
  throw new ModelAndViewDefiningException(modelAndView);
}

로그인 정보 확인 방법

LoginVO loginVO = (LoginVO) req.getSession().getAttribute("loginVO");

jsp에서 로그인 유지 처리

유저 정보 저장

<c:if test="${!empty LoginUser}">
  <c:set var="login_key" value="${LoginUser.유저고유ID}"/>
  <c:set var="login_id" value="${LoginUser.유저아이디}"/>
  <c:set var="login_name" value="${LoginUser.유저명}"/>
  <c:set var="login_dateTime" value="${LoginUser.로그인시간}"/>
  <c:set var="login_userType" value="${LoginUser.유저타입}"/>
</c:if>

모든 페이지에 include 되는 공통 JSP 안에, 서버에서 받아온 유저 정보들을 JSTL 변수에 담아줍니다.

기존 로그인 유지 처리

<c:if test="${empty login_key && 로그인페이지여부 eq 'N'}">
  $.ajax({
      url: "https://SSOAPI주소:446/api/auth/API1",
      data: { sessionData: "${_sessid}" },
      dataType: 'json',
      cache : false,
      success: function (data) {
          if (data && data.Result) {
              let Items = data.Result.Items;
              $.ajax({
                  url: "https://SSOAPI주소:446/api/auth/API2?Token=" + Items.Token,
                  dataType: 'text',
                  xhrFields: {
                      withCredentials: true
                  },
                  cache : false,
                  success: function (data) {
                      let reg = /_ChunjaeSSOEncData = '(.*)';/
                      let ssoData = reg.exec(data)[1];
                      if (ssoData) {
                          $.ajax({
                              url: "<c:url value="/세션로그인처리.do" />",
                              data: {
                                  ssoData: ssoData
                              },
                              type: "post",
                              success: function (res) {
                                const obj = JSON.parse(res);
                                if(obj.result==1){
                                    location.reload();
                                }
                              }
                          })
                      }
                  }
              });
          }
      }
  });
</c:if>

유저고유ID가 변수에 저장되어 있지 않고, 로그인 페이지가 아니면 로그인 유지 SSO API를 호출합니다.
첫 번째 API에 _sessid를 파라미터로 넘기고, 유효성 검증을 위한 유저 토큰을 받고 SSO 쿠키도 생성 받습니다.
두 번째 API에서 유저 토큰을 파라미터로 넘기고, 유저 ID가 암호화된 SSO 쿠키 값이 인증되면 유저 정보가 암호화된 SSO 데이터를 받을 수 있습니다.
SSO 데이터가 있으면 신규 세션 로그인 처리 Controller URL에 파라미터로 넘기면서 ajax로 호출합니다.
Controller에서 SSO 데이터의 유저 정보를 복호화하고 LoginVO에 담아 세션에 저장하여 로그인 처리 합니다.

기존에는 Javascript에서 사내 SSO 서버 API를 호출하여 로그인 유지하게 되어 있었는데,
Javascript에서 호출하면 크로스도메인 및 보안 문제가 발생하니 JAVA에서 호출하는 것이 좋습니다.


SSO 로그인 기능 개발

타 팀 앱에서 우리 웹으로 이동 시 SSO 로그인 유지되는 처리를 개발하게 되었습니다.
앱에서 우리 웹의 신규 API로 암호화된 userkey를 보내주면,
첫 번째 SSO API에 userkey를 넘기고, 두 번째 SSO API에서 토큰과 쿠키를 넘기면 유저 정보를 응답해주기로 했습니다.
그런데, 첫 번째 SSO API에서 생성해준 쿠키는 타 사이트 URL 쿠키라 우리 사이트에서 활용할 수 없었습니다.

원인 분석 및 해결방안
안드로이드:삼성 인터넷 브라우저, iOS:사파리 등 모바일 기본 브라우저에서는 보안상 타 사이트 쿠키가 차단되어,
도메인이 다른 Cross site 쿠키 공유가 되지 않아 쿠키 확인을 통한 로그인이 되지 않습니다.
결국 유저 쿠키 없이 유저 토큰만 받고도 사용자 정보를 응답하는 신규 SSO API를 생성 요청하여 받았고,
두 번째 SSO API 주소를 변경하여 해결하였습니다.

SSO 로그인 장점
한 번의 로그인으로 여러 사이트 로그인 연동이 가능해서, 각각의 서비스마다 로그인하는 불편함이 해소됩니다.

Controller 코드

@RequestMapping(value = "/SSO로그인.do")
  public String actionLoginAppProfSso(@RequestParam("userkey") String userkey, HttpServletRequest request, ModelMap model, HttpServletResponse res) throws Exception {
    try {
      // userkey로 SSO API 인증하여 유저 정보 받아오기
      LoginSso result = loginAPIUtil.loginAppProf(userkey);
      
      if (result.getUserID() != null && !result.getUserID().equals("")) {

        // 로그인 해도 되는 유저인지 체크 (생략)

        // User 테이블 조회 후 없으면 Insert
        LoginVO resultData = ssoUserInsertSelect(result);

        // 세션 로그인 처리
        request.getSession().setAttribute("loginVO", resultData);
        
        // 로그인 성공 시
        return "redirect:/메인화면.do";
      }
    } catch (Exception e) {
			// 에러 시
			return "view/로그인화면jsp명";
		}
		
  // 로그인 실패 시
  return "view/로그인화면jsp명";
}

loginAPIUtil 코드

public static LoginSso loginAppProf(String userkey) throws Exception {
  LoginSso loginSso = new LoginSso();
  
  // 앱에서 보내준 암호화 userkey 복호화
  AES256Util aes256Util = new AES256Util(프로퍼티유틸.SSOAESKEY);
  String userkeyDecode = aes256Util.aesDecode(userkey);
  
  // 원본 userkey 암호화
  String userkeyEncode = URLEncoder.encode(AES256암호화함수(userkeyDecode), "UTF-8");
  
  URLConnectionUtil urlConnectionUtil = new URLConnectionUtil();

  // API1 호출 : 유저 key로 유저 토큰 생성
  String api1ResultStr = urlConnectionUtil.requestGetURL("https://SSOAPI주소/api/auth/API1?sessionData=" + userkeyEncode, "application/json");
  
  JSONParser parser = new JSONParser();
  JSONObject jsonObj = (JSONObject) parser.parse(api1ResultStr);
  
  if (jsonObj.get("StatusCode") != null) {
    String StatusCode = (String) jsonObj.get("StatusCode");
    if ("AUTH_OK".equals(StatusCode)) {

      JSONObject result = (JSONObject) jsonObj.get("Result");
      JSONObject items = (JSONObject) result.get("Items");
      String token = (String) items.get("Token");
      
      // API2 호출 : 유저 토큰으로 유저 데이터 받음
      String api2ResultStr = urlConnectionUtil.requestGetURL("https://SSOAPI주소/api/auth/API2?Token=" + token, "text/html");
      
      // API2 결과에서 암호화 유저 데이터만 추출
      Pattern pattern = Pattern.compile("_ChunjaeSSOEncData = '(.*)';");
      Matcher matcher = pattern.matcher(api2ResultStr);
      String ssoEncData = "";
      
      while (matcher.find()) {
        ssoEncData = matcher.group(1);
      }

      // 유저 데이터 복호화
      loginSso = AES256복호화함수(ssoEncData);
      
    }
    
    loginSso.setStatusCode(StatusCode);
  }
    
  return loginSso;
}

public static String AES256암호화함수(String userkeyDecode) {

  String ssoFormat = MessageFormat.format("{0}^{1}^{2}", userkeyDecode, "특정문자열", new Date().getTime());

  AES256Util aes = new AES256Util(프로퍼티유틸.SSOAESKEY);
  String ssoStr = aes.aesEncode(ssoFormat);

  return ssoStr;
}

public static LoginSso AES256복호화함수(String SSOEncData) {

  LoginSso result = null;

  AES256Util aes256Util = new AES256Util(프로퍼티유틸.SSOAESKEY);

  String decodeString = URLDecoder.decode(SSOEncData, StandardCharsets.UTF_8.name());
  String SSODesData = aes256Util.aesDecode(decodeString);

  Gson gson = new Gson();

  result = gson.fromJson(SSODesData, LoginSso.class);
  result.setUsertype("T");

  return result;
}

URLConnectionUtil 코드

https://0songha0.github.io/web-dev/2023-12-13-1
Java API 호출을 위해 생성한 객체입니다.


동시접속 로그인 불가 처리

동시접속 시 로그아웃 예시

<c:if test="${!empty LoginUser.userId}">
<script type="text/javaScript" language="javascript">
  chekLoginOver = function(){
    $.ajax({
        url:'${pageContext.request.contextPath}/cmmn/chek/session.do',
        type:'post',
        data:{userId: '${LoginUser.userId}'},
        dataType: "json" ,
        success: function(data) {
          if(data.result == -3){
            alert('※동시접속 종료안내 \n다른 시스템에서 같은 아이디로 동시 로그인 되어 종료됩니다.');
            logout();
          }
        },
        error: function(err) {
        }
    });
    return false;
  }

  chekLoginOver();
</script>
</c:if>

로그인 성공 시 이동하는 화면에 위와 같은 스크립트를 추가하면 동시접속을 막을 수 있습니다.
세션 로그인 유저 정보가 있다면, 마지막 로그인 세션 ID를 유저 로그인 상태 테이블에서 조회합니다.
현재 세션 ID와 동일하다면 동시접속 종료 팝업 표출 후 로그아웃 함수를 호출합니다.

세션 체크 Controller 참고

@RequestMapping ("/cmmn/chek/session.do")
public ModelAndView chekSession(HttpServletRequest req, HttpServletResponse res) throws Exception {
  ModelAndView mav = new ModelAndView();

  String userId = req.getParameter("userId");
  String sesinId = req.getSession().getId();
  int result = 0;
  
  // 인증된 사용자 여부
  boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
  
  // DB에서 마지막 로그인 세션 ID 조회 
  IMLgnSttsVO lgnStts = lgnSttsService.selectDetailLgnStts(userId);
  
  if (isAuthenticated && lgnStts != null) {
    if (!sesinId.equals(lgnStts.getLastSesinId())) {
      LoginVO loginVO = (LoginVO) req.getSession().getAttribute("loginVO");
			
      // 일부 계정을 제외하고 동시접속 차단
      switch (loginVO.getId()) {
        case "tsherpagt006":
          result = 1;
          break;
        default:
          result = -3;
          break;
      }
    }
  }

  mav.addObject("result", result);
  mav.setViewName("jsonView");

  return mav;
}

SSO API 방화벽 허용

SSO API Java 요청 예시

// 클래스 리스트 요청
URL url = new URL("http://SSOAPI주소/api/userClassSearch?UserID=" + UserID);

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
BufferedReader in = null;
String returnString = "";

try {
  in = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));

  String inputLine;
  while ((inputLine = in.readLine()) != null) {
    returnString = returnString.concat(inputLine);
  }

} finally {
  if (in != null) {
    in.close();
  }
}

// 응답 JSON 파싱
JSONParser parser = new JSONParser();
Object obj;
obj = parser.parse(returnString);
JSONObject jsonObj = (JSONObject) obj;
if (jsonObj.get("status") != null) {
  String status = (String) jsonObj.get("status");
  if ("200".equals(status)) {

    Gson gson = new Gson();

    ClassSso cls = gson.fromJson(returnString, ClassSso.class);

    if (cls != null && !cls.getData().isEmpty()) {
      List<ClassInfo> list = cls.getData();
      if (list != null && !list.isEmpty()) {
        List<ClassInfo> sortlist = new ArrayList<ClassInfo>();
        for (ClassInfo info : list) {
          if ("Y".equals(info.getIsMain())) {
            sortlist.add(info);
          }
        }
        return sortlist;
      } else {
        return list;
      }
    } else {
      return null;
    }

  }
}

유저가 속한 클래스 리스트를 받아오는 SSO API 예시입니다.
http로 호출하므로, 개발서버에서 SSO 서버로 나가는 80 포트 방화벽을 허용해야 데이터를 받을 수 있습니다.
만약, https로 호출한다면 443 기본 포트 방화벽을 허용하면 됩니다.
:포트 번호가 명시되어 있으면 기본 포트 대신 해당 서비스 포트 번호만 허용하면 됩니다.

Leave a comment