text/Java

로그인 로직 구현해보기 (jsp, java, tomcat)

hoonzii 2022. 1. 15. 09:30
반응형

정리

프로젝트중 로그인 부분을 구현해야 하는 것이 있었다.

당시 로그인 부분보다 더 급한 부분을 먼저 처리 하느라 로그인의 경우 보안적으로 무방비한 상태였다.

 

유저의 id, password를 받아 db 정보와 대조해본 뒤, 맞으면 넘어가고 틀리면 다시 로그인 페이지로 넘기는 단순한 로직인데 이때 id, password가 전혀 암호화가 이뤄지지 않은 상태로 네트워크 상을 돌아다니게 된다.

개발자 친구는 적어도 md5로 암호화 한뒤 보내는게 어떻겠냐고 제안할 정도였다.

그래서 마음 한켠으로 ‘아 언젠간 고쳐야지...’ 같이 생각만 하고 있다가 이번에 고치게 됐는데, 이거에 대해 간략히 정리한다.

지금 만들고 있는 웹은 java + jsp + tomcat으로 흔히들 사용하는 spring 이 아니라서

java,jsp 로그인 보안 이라고 쳐도 딱히 맘에 드는게 나오지 않던 와중에 어느 현자의 블로그 글을 보고 구현이 가능하게 되었다.

기존에 내가 구현한 로그인에 대해서 간략히 살펴보자.

  1. login.jsp (사실상 index.jsp, 처음 랜딩되는 페이지)
    1. 사용자는 id, password를 입력해 “/loginCheck.jsp” 주소로 submit을 날린다.
  2. loginCheck.jsp
    1. 날라온 id, password 값을 디비에 조회해 실제 있는 회원인지 조회한 다음 유효할 경우 2번루트로.
      1. 2번 루트부터는 id값이 아닌 사용자별 고유번호로 사용자 판별 (member_seq)
    2. 아니라면 다시 login.jsp 페이지로 움직인다. (3번루트)
  3. signIn.jsp (회원가입)
    1. 사용자가 회원가입을 원할경우 signIn.jsp 페이지로 이동한다.
    2. 해당 페이지에서 id, password, email 정보를 입력한 뒤, “/signCheck.jsp” 로 중복이 아닌지 체크한다.
  4. signCheck.jsp
    1. 날라온 id, password가 중복이 아닌지 체크한뒤, 중복이라면 중복, 아니라면 아니다 표시를 한다.
    2. 생성한 id로 로그인을 위해 login.jsp 페이지로 이동한다 (6번 루트)

위 상황에서 id, password 가 네트워크 상을 돌아다닐때 암호화가 전혀 되지 않았다.

암호화가 필요한 상황은 위 그림으로 볼때 1번 루트, 5번 루트 일 것이다.

나와 같은 고민을 한 사람의 블로그 글을 찾았고,

RSA 기반 웹페이지 암호화 로그인

 

RSA 기반 웹페이지 암호화 로그인

PS>아래 내용은 보안상 검증된 것이 아닙니다. 잘 확인하고 사용하세요.웹 페이지에서 SSL 없이 RSA 암호화 로그인 하기 사용자의 비밀번호를 전송할 때는 SSL 등의 처리를 하지 않으면 해당 비밀번

kwon37xi.egloos.com

비대칭키 방법을 사용하는데, 사용자 정보는 공개된 공개키를 이용해 “암호화” / 서버로 넘어온 암호는 개인키를 이용해 “복호화” 한뒤, 검증 맞으면 로그인 성공이라는 해결 방안이였다.

  1. 사용자가 첫페이지 랜딩을 하기 전, 서버로 요청이 들어오면 서버는 public key / private key 두개를 미리 마련한다.
  2. public key는 request에, private key는 session에 담아 클라이언트에 전달한다.
  3. 사용자는 id, password를 입력하고 로그인 버튼을 누르면, 클라이언트 페이지는 해당 내용을 가로채 public key로 입력값을 암호화 한다.
  4. 넘어오는 값으로는 public key로 암호화 된 정보와 session 일련번호. session 일련번호로 서버가 저장한 private key를 불러온 뒤, 사용자 정보를 복호화 한다.
  5. 검증! → 로그인 완료!

세션에 대한 개념은 여기서...

쿠키(Cookie)와 세션(Session) & 로그인 동작 방법

 

쿠키(Cookie)와 세션(Session) & 로그인 동작 방법

Jihun's Development Blog

cjh5414.github.io

근데 문제는 내 프로젝트의 구조였다. spring 인 경우, @Controller(path)를 통해 get,post 요청에 대한 값을 아주 쉽게 조절할 수 있지만 내껀 그것보다 원시적인 스택이여서 사용자 요청에 대한 get,post 설정부터 오리무중이였다.

 

방법이 있었다.

[Web] web.xml 설정 내용, 역할 및 간단한 예시 이해하기 - Heee's Development Blog

 

[Web] web.xml 설정 내용, 역할 및 간단한 예시 이해하기 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

web.xml 설정을 통해 특정 요청을 java class 파일(servlet)에 매핑할 수 있게 되었다. (ex. @Controller(”/login”))

web.xml 파일 설정 예시

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" version="2.5">
  <display-name>simpleLoginTest</display-name>
	<!--맨처음 도달하는 페이지 정보/ 위에서 아래로 하나씩 찾아가면서 있다면 첫페이지 로딩-->
  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
    <welcome-file>index.htm</welcome-file>
    <welcome-file>index.jsp</welcome-file>
    <welcome-file>default.html</welcome-file>
    <welcome-file>default.htm</welcome-file>
    <welcome-file>default.jsp</welcome-file>
  </welcome-file-list>
  
  <!-- 1. aliases 설정 -->
    <servlet>
        <servlet-name>login</servlet-name>
        <servlet-class>LoginModule.loginServlet</servlet-class>
    </servlet>

    <!-- 2. 매핑 -->
    <servlet-mapping>
        <servlet-name>login</servlet-name>
        <url-pattern>/login</url-pattern>
    </servlet-mapping>
</web-app>

서버는 “/login” 요청시 LoginModule.loginServlet 에 도달한 뒤, 요청을 실행한다.

내 경우, index.jsp 를 실행하는데 index.jsp 는 이렇게 만들었다.

<jsp:forward page="/login"/>

무조건 /login 요청을 타게끔 만든 것이다!

(누군가에겐 너무 당연하겠지만 난 알아낸 순간 유레카를 외쳤다.)

그렇가면 loginServlet 은 어떤지 알아봐야 한다.

// 우선 해당 class는 servlet이라는 알린뒤..
public class loginServlet extends HttpServlet{
		// GET, POST 를 맵핑한다.
	
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // "필요한 동작"을 한다!
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // "필요한 동작"을 한다!
    }
}

위에 서술한듯이 필요한 동작은 다음과 같다.

  1. public key, private key 발급
  2. 해당 정보를 request, session에 할당
  3. login.jsp 로 랜딩

3가지이다.

그럼 코드로 보자

package LoginModule;

import java.io.IOException;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;

import javax.crypto.Cipher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;

public class loginServlet extends HttpServlet{
	
	public static final int KEY_SIZE = 1024;

    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(KEY_SIZE);
            
            KeyPair keyPair = generator.genKeyPair();
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");

            PublicKey publicKey = keyPair.getPublic();
            PrivateKey privateKey = keyPair.getPrivate();

            HttpSession session = request.getSession();
            // 세션에 공개키의 문자열을 키로하여 개인키를 저장한다.
            session.setAttribute("__rsaPrivateKey__", privateKey);

            // 공개키를 문자열로 변환하여 JavaScript RSA 라이브러리 넘겨준다.
            RSAPublicKeySpec publicSpec = (RSAPublicKeySpec) keyFactory.getKeySpec(publicKey, RSAPublicKeySpec.class);

            String publicKeyModulus = publicSpec.getModulus().toString(16);
            String publicKeyExponent = publicSpec.getPublicExponent().toString(16);

            request.setAttribute("publicKeyModulus", publicKeyModulus);
            request.setAttribute("publicKeyExponent", publicKeyExponent);

            request.getRequestDispatcher("/login/loginForm.jsp").forward(request, response);
        } catch (Exception ex) {
            throw new ServletException(ex.getMessage(), ex);
        }
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }
}

post, get 요청에 processRequest 함수를 실행하고

해당 함수는 키를 생성, login.jsp로 이동한다.

위 페이지로 public key가 제대로 전달됐는지 서버를 켜서 확인해보자.

제대로 동작한 걸 확인할 수 있다.

그럼 login.jsp 에 id, password 입력과 암호화 해 /loginCheck 에 넘기는걸 보자

우선 jsp 파일을 수정한다.

 

<%@ page language="java" contentType="text/html; charset=EUC-KR"
    pageEncoding="EUC-KR"%>
<%

String publicKeyModulus = (String) request.getAttribute("publicKeyModulus");
String publicKeyExponent = (String) request.getAttribute("publicKeyExponent");

%>
<!DOCTYPE html>
<html>
<head>
<meta charset="EUC-KR">
<title>Insert title here</title>
<script type="text/javascript" src="<%=request.getContextPath()%>/js/rsa/jsbn.js"></script>
<script type="text/javascript" src="<%=request.getContextPath()%>/js/rsa/rsa.js"></script>
<script type="text/javascript" src="<%=request.getContextPath()%>/js/rsa/prng4.js"></script>
<script type="text/javascript" src="<%=request.getContextPath()%>/js/rsa/rng.js"></script>
<script type="text/javascript" src="<%=request.getContextPath()%>/js/login.js"></script>
</head>
<body>
	<div>
        <label for="username">사용자ID : <input type="text" id="username" size="16"/></label>
        <label for="password">비밀번호 : <input type="password" id="password" size="16" /></label>
        <input type="hidden" id="rsaPublicKeyModulus" value="<%=publicKeyModulus%>" />
        <input type="hidden" id="rsaPublicKeyExponent" value="<%=publicKeyExponent%>" />
        <a href="<%=request.getContextPath()%>/loginFailure.jsp" onclick="validateEncryptedForm(); return false;">로그인</a>
    </div>
    
    <form id="securedLoginForm" name="securedLoginForm" action="<%=request.getContextPath()%>/loginCheck" method="post" style="display: none;">
        <input type="hidden" name="securedUsername" id="securedUsername" value="" />
        <input type="hidden" name="securedPassword" id="securedPassword" value="" />
    </form>
</body>
</html>

위 참고 블로그의 로그인 폼을 가져왔다.

validateEncryptedForm js 함수는 딱히 눈여겨 보지 않았다.

java의 암호화를 구현한 함수라고 생각하면 편할 것이다.

 

중요한건 밑에 securedLoginForm 이다.

validateEncryptedForm함수를 통해 securedLoginForm이 채워지고 /loginCheck 로 post 요청을 날린다.

그렇다면 web.xml에 해당 요청에 대한 정보를 추가해줘야 한다.

<!-- web.xml 에 추가 -->
<servlet>
    <servlet-name>loginCheck</servlet-name>
    <servlet-class>LoginModule.loginCheckServlet</servlet-class>
</servlet>

<servlet-mapping>
    <servlet-name>loginCheck</servlet-name>
    <url-pattern>/loginCheck</url-pattern>
</servlet-mapping>

LoginModule 패키지의 loginCheckServlet 클래스는 넘어온 request (공개키로 암호화된 정보) 와

session ID를 통해 해당 클라이언트의 private key를 가져온뒤!

사용자 정보를 복호화 한다.

 

해당 코드를 보자 (해당 코드는 위 블로그 주소에서 참고...!)

package LoginModule;

import java.io.IOException;
import java.math.BigInteger;
import java.security.PrivateKey;

import javax.crypto.Cipher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;

public class loginCheckServlet extends HttpServlet{
	protected boolean validateInfo(String username, String pw) {
		if(username.equals("test") && pw.equals("1234")) {
			return true;
		}else {
			return false;
		}
	}
	
	/**
     * 암호화된 비밀번호를 복호화 한다.
     */
    protected void processRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String securedUsername = request.getParameter("securedUsername");
        String securedPassword = request.getParameter("securedPassword");

        HttpSession session = request.getSession();
        PrivateKey privateKey = (PrivateKey) session.getAttribute("__rsaPrivateKey__");
        session.removeAttribute("__rsaPrivateKey__"); // 키의 재사용을 막는다. 항상 새로운 키를 받도록 강제.

        if (privateKey == null) {
            throw new RuntimeException("암호화 비밀키 정보를 찾을 수 없습니다.");
        }
        try {
            String username = decryptRsa(privateKey, securedUsername);
            String password = decryptRsa(privateKey, securedPassword);
            
            // 귀염뽀짝 검증로직
            if(this.validateInfo(username, password)) {
            	request.setAttribute("username", username);
            	request.setAttribute("password", password);
            	request.getRequestDispatcher("/login/login.jsp").forward(request, response);
            }else {
            	request.getRequestDispatcher("login").forward(request, response);
            }
        } catch (Exception ex) {
            throw new ServletException(ex.getMessage(), ex);
        }
    }

    private String decryptRsa(PrivateKey privateKey, String securedValue) throws Exception {
        System.out.println("will decrypt : " + securedValue);
        Cipher cipher = Cipher.getInstance("RSA");
        byte[] encryptedBytes = hexToByteArray(securedValue);
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
        byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
        String decryptedValue = new String(decryptedBytes, "utf-8"); // 문자 인코딩 주의.
        return decryptedValue;
    }

    /**
     * 16진 문자열을 byte 배열로 변환한다.
     */
    public static byte[] hexToByteArray(String hex) {
        if (hex == null || hex.length() % 2 != 0) {
            return new byte[]{};
        }

        byte[] bytes = new byte[hex.length() / 2];
        for (int i = 0; i < hex.length(); i += 2) {
            byte value = (byte)Integer.parseInt(hex.substring(i, i + 2), 16);
            bytes[(int) Math.floor(i / 2)] = value;
        }
        return bytes;
    }

    /**
     * BigInteger를 사용해 hex를 byte[] 로 바꿀 경우 음수 영역의 값을 제대로 변환하지 못하는 문제가 있다.
     */
    @Deprecated
    public static byte[] hexToByteArrayBI(String hexString) {
        return new BigInteger(hexString, 16).toByteArray();
    }

        public static String base64Encode(byte[] data) throws Exception {
        BASE64Encoder encoder = new BASE64Encoder();
        String encoded = encoder.encode(data);
        return encoded;
    }

    public static byte[] base64Decode(String encryptedData) throws Exception {
        BASE64Decoder decoder = new BASE64Decoder();
        byte[] decoded = decoder.decodeBuffer(encryptedData);
        return decoded;
    }

    // <editor-fold defaultstate="collapsed" desc="HttpServlet methods. Click on the + sign on the left to edit the code.">
    /** 
     * Handles the HTTP <code>GET</code> method.
     * @param request servlet request
     * @param response servlet response
     * @throws ServletException if a servlet-specific error occurs
     * @throws IOException if an I/O error occurs
     */
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }

    /** 
     * Handles the HTTP <code>POST</code> method.
     * @param request servlet request
     * @param response servlet response
     * @throws ServletException if a servlet-specific error occurs
     * @throws IOException if an I/O error occurs
     */
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        processRequest(request, response);
    }
}

validateInfo에 귀염뽀짝하게 id, pw 검증로직을 만들었고 (실제는 디비에 조회!)

맞다면 다음 화면, 틀리다면 “/login” 을 호출 → 다시 로그인 화면으로 이동하게끔 한다.

signIn.jsp → signCheck.jsp 역시 위 과정대로 호출하면 된다! ( 전부쓰기에는...구찮)

 

아 또 한가지 애로사항이 있었는데 그전에 경로를 확인해보자

loginCheckServlet.jsp 에서 체크가 완료되었을때 바로 home.jsp 로 빠지질 않았다.

왠지는 모르겠고, 계속 오류가 나길래 꼼수를 하나쓴게

 

“/”호출 → 키생성 및 발급 → 로그인 정보 입력 & 암호화 전송 → 복호화&체크 → home.jsp 입장이 아니라

 

“/”호출 → 키생성 및 발급 → 로그인 정보 입력 & 암호화 전송 → 복호화&체크 및 member_seq값 반환 → loginCheck.jsp 들어온뒤, 해당 seq가 유효한 값인지 체크(js) & forward Home.jsp

 

바로 넘어가지 않길래 loginCheck.jsp를 /login 폴더에 생성한 뒤 그 페이지에서 Home.jsp 로 이동하게끔 변경했었다.

정리끝!

반응형