본문 바로가기

[Recoeve.net]

Log-in encrypt and remembering, or keep connected, or session

# Log-in 암호화 및 유지 (Log-in encrypt and remembering, or keep connected, or session) Log-in 암호화 및 유지하는거 정리. ## PH
  • 2016-02-09 : First posting.
## TOC ## Encryption : Sign-up and Explicit log-in ### Javascript Hash (encrypt) function
Javascript hash ```[.linenums.scrollable.lang-js] eve.hash1=function(str) { var h=-17-str.length; for (var i=0;i<str.length;i++) { h+=str.charCodeAt(i); h+=(h<<10); h^=(h>>>6); } h+=(((h>>>0)%1318489)<<7); // prime from http://www.gutenberg.org/cache/epub/65/pg65.html.utf8 h+=(h<<3); h^=(h>>>11); h+=(h<<15); h=h>>>0; return eve.pad(h.toString(16), 8); }; eve.encrypt=function(salt, pwd, iter) { iter=pwd.length+131+((iter&&iter.constructor==Number&&iter>=0)?iter:0);; pwd=salt+pwd; var h1=eve.hash1(pwd); var h2=eve.hash2(pwd); var h3=eve.hash3(pwd); var h4=eve.hash4(pwd); var h5=eve.hash5(pwd); var h6=eve.hash6(pwd); var h7=eve.hash7(pwd); var h8=eve.hash8(pwd); var h9=eve.hash9(pwd); var h10=eve.hash10(pwd); var h11=eve.hash11(pwd); var h12=eve.hash12(pwd); var h13=eve.hash13(pwd); var tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7, tmp8, tmp9, tmp10, tmp11, tmp12, tmp13; for (var i=0;i<iter;i++) { tmp1=h13+h12+h11+h10+h9+salt+h8+h7+h6+h5+h4+h3+h2+h1; tmp2=h1+h3+salt+h2; tmp3=salt+h2+h8+h1+h3; tmp4=h7+salt+h5; tmp5=h4+salt+h8; tmp6=h10+h13+salt+h6; tmp7=h6+h1+h9+salt; tmp8=h9+salt+h10; tmp9=h7+salt+h12; tmp10=h11+salt+h5; tmp11=h4+salt+h13+h2; tmp12=h11+salt+h6; tmp13=h4+h12+salt+h8; h1=eve.hash1(tmp1); h2=eve.hash2(tmp2); h3=eve.hash3(tmp3); h4=eve.hash4(tmp4); h5=eve.hash5(tmp5); h6=eve.hash6(tmp6); h7=eve.hash7(tmp7); h8=eve.hash8(tmp8); h9=eve.hash9(tmp9); h10=eve.hash10(tmp10); h11=eve.hash11(tmp11); h12=eve.hash12(tmp12); h13=eve.hash13(tmp13); } return h1+h2+h3+h4+h5+h6+h7+h8+h9+h10+h11+h12+h13; }; ```/
### JAVA Hash (encrypt) function Server 에서는 recoeve.db.Encrypt.java 이용.
JAVA hash ```[.linenums.scrollable.lang-java] public class Encrypt { public static final int iterFull=10000; public static String pad(String str, int max) { return (str.length()<max)?pad("0"+str,max):str; } public static String hash1(String str) { int h=-17-str.length(); for (int i=0;i<str.length();i++) { h+=str.codePointAt(i); h+=(h<<10); h^=(h>>>6); } h+=( ((int)((h&0xffff_ffffL)%1318489))<<7 ); // prime from http://www.gutenberg.org/cache/epub/65/pg65.html.utf8 h+=(h<<3); h^=(h>>>11); h+=(h<<15); return pad(Integer.toHexString(h), 8); } public static String encrypt0(String salt, String h, int iter) { String tmp1=null, tmp2=null, tmp3=null, tmp4=null, tmp5=null, tmp6=null, tmp7=null, tmp8=null, tmp9=null, tmp10=null, tmp11=null, tmp12=null, tmp13=null; int i=0; String h1=h.substring(i,i+8); i+=8; String h2=h.substring(i,i+8); i+=8; String h3=h.substring(i,i+8); i+=8; String h4=h.substring(i,i+8); i+=8; String h5=h.substring(i,i+8); i+=8; String h6=h.substring(i,i+8); i+=8; String h7=h.substring(i,i+8); i+=8; String h8=h.substring(i,i+8); i+=8; String h9=h.substring(i,i+8); i+=8; String h10=h.substring(i,i+8); i+=8; String h11=h.substring(i,i+8); i+=8; String h12=h.substring(i,i+8); i+=8; String h13=h.substring(i,i+8); i+=8; for (int k=0;k<iter;k++) { tmp1=h13+h12+h11+h10+h9+salt+h8+h7+h6+h5+h4+h3+h2+h1; tmp2=h1+h3+salt+h2; tmp3=salt+h2+h8+h1+h3; tmp4=h7+salt+h5; tmp5=h4+salt+h8; tmp6=h10+h13+salt+h6; tmp7=h6+h1+h9+salt; tmp8=h9+salt+h10; tmp9=h7+salt+h12; tmp10=h11+salt+h5; tmp11=h4+salt+h13+h2; tmp12=h11+salt+h6; tmp13=h4+h12+salt+h8; h1=hash1(tmp1); h2=hash2(tmp2); h3=hash3(tmp3); h4=hash4(tmp4); h5=hash5(tmp5); h6=hash6(tmp6); h7=hash7(tmp7); h8=hash8(tmp8); h9=hash9(tmp9); h10=hash10(tmp10); h11=hash11(tmp11); h12=hash12(tmp12); h13=hash13(tmp13); } return h1+h2+h3+h4+h5+h6+h7+h8+h9+h10+h11+h12+h13; }; public static String encrypt(String salt, String pwd, int iter) { iter=pwd.length()+131+((iter>=0)?iter:0); pwd=salt+pwd; return encrypt0( salt , hash1(pwd)+hash2(pwd)+hash3(pwd)+hash4(pwd)+hash5(pwd)+hash6(pwd)+hash7(pwd)+hash8(pwd)+hash9(pwd)+hash10(pwd)+hash11(pwd)+hash12(pwd)+hash13(pwd) , iter ); } public static String encryptRest(String salt, String pwd, int iter) { return encrypt0(salt, pwd, iterFull-iter); } } ```/
### 동작 Javascript/JAVA Hash (encrypt) function 들이 동일하게 동작하도록 만들어 놓음. :: vs Server 로부터 salt 와 iteration 을 받아서 그 salt 로 iteration 만큼 client side javascript 에서 단방향 hash 암호화를 진행. 나머지 iteration 은 server 측에서 JAVA 로 돌리고, password 가 byte 비교로 일치하는지 확인. Server 에서는 class recoeve.db.Encrypt 를 통해 나머지 iteration 을 진행함. Interation 을 하나씩 줄이면서 보안을 확보. Rainbow table attack 에 강함. User specific salt. 13개 hash function 을 이용한 단방향 암호화로 모든 가능성에 대한 rainbow table 을 만드는것이 거의 불가능에 가깝다고 볼 수 있을듯? 필요한 Rainbow table 용량이 user 1명당 대략 10 TB 이상이면 안전하다고 볼 수 있는걸래나? (이 계산은 해보지 않긴 했음.) "8 byte * 13" map to "8 byte * 13" 일테니, 대략 salt 1개당 100 byte * 100 byte? 이렇게가 아니고, 2^{800} 정도가 필요한건가? Salt + 13개 hash function 을 섞어서 단방향 암호화하는 ecrypt function 의 역함수를 찾아낼수 있으려나? 단방향 암호화라 지워지는 informations, 섞이는 informations 가 많아서 역함수 찾는건 불가능 하다고 생각하긴 하는데... ## Sign up ### boolean createAuthToken() Bot 돌려서 아이디 생성하는걸 막기위해, ID and e-mail check 를 할때 (path=/account/check), Authorization Token 을 만들어서 보냄.
createAuthToken() ```[.linenums.scrollable.lang-java] public boolean createAuthToken(String t, String ip, byte[] token) { try { con.setAutoCommit(true); pstmtCreateAuthToken.setString(1, t); pstmtCreateAuthToken.setString(2, ip); pstmtCreateAuthToken.setBytes(3, token); return (pstmtCreateAuthToken.executeUpdate()>0); } catch (SQLException e) { err(e); } return false; } ```/
### boolean checkAuthToken() path=/account/sign-up 에서 checkAuthToken() 을 수행해서 authToken 확인.
checkAuthToken() ```[.linenums.scrollable.lang-java] public boolean checkAuthToken(BodyData inputs, String ip, String now) { String t=inputs.get("tToken"); String token=inputs.get("authToken"); String id=inputs.get("userId"); String email=inputs.get("userEmail"); try { con.setAutoCommit(true); pstmtCheckAuthToken.setString(1, t); pstmtCheckAuthToken.setString(2, ip); ResultSet rs=pstmtCheckAuthToken.executeQuery(); String errMsg="Sign-up error: "; if (rs.next()) { boolean newC=rs.getBoolean("new"); boolean tokenC=Arrays.equals(rs.getBytes("token"), unhex(token)); boolean timeC=checkTimeDiff(now, t, "00:01:30"); if (newC&&tokenC&&timeC) { rs.updateBoolean("new", false); rs.updateRow(); logs(1, now, ip, "tkn", true, "tToken: "+t); return true; } else { if (!newC) { errMsg+="used token"; }errMsg+=", "; if (!tokenC) { errMsg+="wrong token"; }errMsg+=", "; if (!timeC) { errMsg+="expired token"; }errMsg+="."; } } else { errMsg+="no token."; } errMsg+="\nID: "+id+", E-mail: "+email+". tToken: "+t; logs(1, now, ip, "tkn", false, errMsg); } catch (SQLException e) { err(e); } return false; } ```/
### boolean createUser() AuthToken 이 확인되면 user 계정을 생성함.
createUser() ```[.linenums.scrollable.lang-java] public boolean createUser(BodyData inputs, String ip, String now) { boolean done=false; String id=inputs.get("userId"); byte[] pwd_salt=unhex( inputs.get("authToken") ); String pwd=inputs.get("userPwd"); String email=inputs.get("userEmail"); String veriKey=hex(randomBytes(32)); ResultSet user=null; try { con.setAutoCommit(false); // pstmtCreateUser=con.prepareStatement("INSERT INTO `Users` (`i`, `id`, `email`, `pwd_salt`, `pwd`, `veriKey`, `ipReg`, `tReg`, `tLastVisit`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);"); pstmtCreateUser.setLong(1, getUserIndexToPut()); pstmtCreateUser.setString(2, id); pstmtCreateUser.setString(3, email); pstmtCreateUser.setBytes(4, pwd_salt); pstmtCreateUser.setBytes(5, pwdEncrypt(pwd_salt, pwd)); pstmtCreateUser.setString(6, veriKey); pstmtCreateUser.setString(7, ip); pstmtCreateUser.setString(8, now); pstmtCreateUser.setString(9, now); if (pstmtCreateUser.executeUpdate()>0) { user=findUserById(id); if (user.next()) { Gmail.sendVeriKey(email, id, veriKey); updateUserClass(0,+1); // 0: Not verified yet logs(user.getLong("i"), now, ip, "snu", true); // sign-up done=true; } } } catch (SQLException e) { err(e); } catch (Exception e) { System.out.println(e); } try { System.out.println("createUser done : "+done); if (done) { con.commit(); } else { con.rollback(); } } catch (SQLException e) { err(e); } return done; } ```/
### boolean verifyUser() Path 에서 한글 id 처리를 위해, java.net.URLDecoder.decode() 로 미리 path 를 처리하고 보냄.
verifyUser() ```[.linenums.scrollable.lang-java] public boolean verifyUser(String cookieI, String path, String ip) { boolean done=false; long user_i=Long.parseLong(cookieI, 16); int i=path.indexOf("/"); String id=path.substring(0,i); String veriKey=path.substring(i+1); String now=now(); try { con.setAutoCommit(false); ResultSet user=findUserByIndex(user_i); if ( user.next() &&user.getString("id").equals(id) &&user.getString("veriKey").equals(veriKey) &&user.getInt("class")==0 &&checkTimeDiff(now, user.getString("tReg"), "24:00:00") ) { // IP check is needed??? updateUserClass(0,-1); // 0: Not verified yet user.updateInt("class", 6); updateUserClass(6,+1); // 6: Initial updateUserClass(-2,+1); // -2: Total number of accounts String email=user.getString("email"); updateEmailStat(email.substring(email.indexOf("@")+1),+1); user.updateString("veriKey", null); user.updateRow(); logs(user_i, now, ip, "vrf", true); // verified. done=true; } } catch (SQLException e) { err(e); } try { if (!done) { con.rollback(); logs(user_i, now, ip, "vrf", false); // not verified. } con.commit(); } catch (SQLException e) { err(e); } return done; } ```/
## Log-in Session ### String getPwdIteration() 우선 password iteration 수 및 pwd_salt 를 path="/account/pwd_iteration" 통해서 요청하고.
getPwdIteration() ```[.linenums.scrollable.lang-java] public String getPwdIteration(String idType, String id) { // idType "id or email" try { con.setAutoCommit(true); ResultSet user=null; if (idType.equals("id")) { user=findUserById(id); } else if (idType.equals("email")) { user=findUserByEmail(id); } if ( user!=null&&user.next() ) { return Integer.toString( user.getInt("pwd_iteration") ) +"\t"+hex( user.getBytes("pwd_salt") ); } else { return "Cannot find a user from id/email."; } } catch (SQLException e) { err(e); } return "SQL Exception."; } ```/
### byte[] pwdEncrypt() 마지막 단계의 password encryption.
pwdEncrypt() ```[.linenums.scrollable.lang-java] public static byte[] pwdEncrypt(byte[] salt, String pwd) throws Exception { // NoSuchAlgorithmException, UnsupportedEncodingException MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); return sha512.digest((new String(salt,"UTF-8")+pwd).getBytes("UTF-8")); } ```/
### String authUser() Path="/account/log-in.do" 를 통해서 session cookie 들을 받음. 참고 : ,
authUser() ```[.linenums.scrollable.lang-java] public String authUser(BodyData inputs, String ip) { boolean done=false; long user_i=1; // anonymous String now=now(); String setCookie=null; try { con.setAutoCommit(false); ResultSet user=null; switch(inputs.get("idType")) { case "id": user=findUserById(inputs.get("userId")); break; case "email": user=findUserByEmail(inputs.get("userId")); break; } if ( user!=null&&user.next() ) { user_i=user.getLong("i"); byte[] salt=user.getBytes("pwd_salt"); int iter=user.getInt("pwd_iteration"); if ( Arrays.equals( pwdEncrypt(salt, Encrypt.encryptRest(hex(salt), inputs.get("userPwd"), iter)) , user.getBytes("pwd") ) ) { user.updateInt("pwd_iteration", iter-1); byte[] session=randomBytes(32); byte[] token=randomBytes(32); int ssnC=user.getInt("ssnC"); setCookie=createUserSession(user_i, now, session, token, ip); user.updateInt("ssnC", ssnC+1); String logDesc=null; String rmb=inputs.get("rememberMe"); if ( rmb!=null && rmb.equals("yes") ) { byte[] rmbdAuth=randomBytes(32); byte[] rmbdToken=randomBytes(32); int rmbdC=user.getInt("rmbdC"); setCookie+=createUserRemember(user_i, now, rmbdAuth, rmbdToken, inputs, ip); user.updateInt("rmbdC", rmbdC+1); logDesc="Remembered"; } user.updateRow(); logs(user_i, now, ip, "lgi", true, logDesc); // log-in success } else { logs(user_i, now, ip, "lgi", false); // log-in fail } } else { logs(user_i, now, ip, "lgi", false); // user_i=1: anonymous (no id/email) log-in try. This must not happen, because of "account/pwd_iteration" check before log-in request. } done=true; } catch (SQLException e) { err(e); } catch (Exception e) { System.out.println(e); } try { if (done) { con.commit(); } else { con.rollback(); } } catch (SQLException e) { err(e); } return setCookie; } ```/
### String createUserSession()
createUserSession() ```[.linenums.scrollable.lang-java] public String createUserSession(long user_i, String now, byte[] session, byte[] token, String ip) throws SQLException { pstmtCreateUserSession.setLong(1, user_i); pstmtCreateUserSession.setString(2, now); pstmtCreateUserSession.setBytes(3, session); pstmtCreateUserSession.setBytes(4, token); pstmtCreateUserSession.setString(5, ip); String setCookie=""; if (pstmtCreateUserSession.executeUpdate()>0) { // user.updateInt("ssnC", user.getInt("ssnC")+1); setCookie="I="+Long.toString(user_i,16)+cookieOptionSSN; // "I=0123ABCD(64bit);path=/;HttpOnly" setCookie+="\nSet-Cookie: tCreate="+now+cookieOptionSSN; // "tCreate=now;path=/;HttpOnly" setCookie+="\nSet-Cookie: SSN="+hex(session)+cookieOptionSSN; // "SSN=hex(session);path=/;HttpOnly" setCookie+="\nSet-Cookie: token="+hex(token)+cookieOptionSSNtoken; // "token=hex(token);max-age=3*60*60;path=/;HttpOnly" } return setCookie; } ```/
### boolean sessionCheck() 위에서 발급받은 session cookie 들을 통해서, session 을 확인함.
sessionCheck() ```[.linenums.scrollable.lang-java] public boolean sessionCheck(Cookie cookie) { if (cookie.get("I")!=null) { long user_i=Long.parseLong(cookie.get("I"), 16); String tCreate=cookie.get("tCreate"); String session=cookie.get("SSN"); String token=cookie.get("token"); if (tCreate!=null&&session!=null&&token!=null) { String now=now(); if (checkTimeDiff(now, tCreate, hoursSSN+":00:00")) { try { pstmtSession.setLong(1, user_i); pstmtSession.setString(2, tCreate); ResultSet rs=pstmtSession.executeQuery(); return ( rs.next() &&Arrays.equals(rs.getBytes("session"), unhex(session)) &&Arrays.equals(rs.getBytes("token"), unhex(token)) ); // No log for sessionCheck. Too much. } catch (SQLException e) { err(e); }}}} return false; } ```/
## Remember me 우선 할때, "remember me" 가 체크되어 있었는지 확인 후 rmbd cookie 들도 발급. ### String createUserRemember()
createUserRemember() ```[.linenums.scrollable.lang-java] public String createUserRemember(long user_i, String now, byte[] rmbdAuth, byte[] rmbdToken, BodyData inputs, String ip) throws SQLException { pstmtCreateUserRemember.setLong(1, user_i); pstmtCreateUserRemember.setString(2, now); pstmtCreateUserRemember.setBytes(3, rmbdAuth); pstmtCreateUserRemember.setBytes(4, rmbdToken); pstmtCreateUserRemember.setString(5, inputs.get("log")); pstmtCreateUserRemember.setInt(6, Integer.parseInt(inputs.get("screenWidth"))); pstmtCreateUserRemember.setInt(7, Integer.parseInt(inputs.get("screenHeight"))); pstmtCreateUserRemember.setString(8, ip); String setCookie=""; if (pstmtCreateUserRemember.executeUpdate()>0) { // user.updateInt("rmbdC", user.getInt("rmbdC")+1); setCookie+="\nSet-Cookie: rmbdI="+Long.toString(user_i,16)+cookieOptionRMB; // rmbdI=0123ABCD(64bit);max-age=(daysRMB*24*60*60);path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdT="+now+cookieOptionRMB; // rmbdT=now;max-age=(daysRMB*24*60*60);path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdAuth="+hex(rmbdAuth)+cookieOptionRMB; // rmbdAuth=hex(rmbdAuth);max-age=(daysRMB:30*24*60*60);path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdToken="+hex(rmbdToken)+cookieOptionRMBtoken; // rmbdToken=hex(rmbdToken);max-age=(daysRMBtoken:10*24*60*60);path=/account/log-in;HttpOnly } return setCookie; } ```/
### String authUserFromRmbd()
authUserFromRmbd() ```[.linenums.scrollable.lang-java] public String authUserFromRmbd(Cookie cookie, BodyData inputs, String ip) { String setCookie="rmbdI="+cookieOptionDelRMB +"\nSet-Cookie: rmbdT="+cookieOptionDelRMB +"\nSet-Cookie: rmbdAuth="+cookieOptionDelRMB +"\nSet-Cookie: rmbdToken="+cookieOptionDelRMB; String now=now(); if (cookie.get("rmbdI")!=null) { long user_i=Long.parseLong(cookie.get("rmbdI"), 16); String rmbdT=cookie.get("rmbdT"); String rmbdAuth=cookie.get("rmbdAuth"); String rmbdToken=cookie.get("rmbdToken"); String log=inputs.get("log"); String screenWidth=inputs.get("screenWidth"); String screenHeight=inputs.get("screenHeight"); if( rmbdT!=null && rmbdAuth!=null && rmbdToken!=null && log!=null && screenWidth!=null && screenHeight!=null ) { try { pstmtCheckUserRemember.setLong(1, user_i); pstmtCheckUserRemember.setString(2, rmbdT); ResultSet rs=pstmtCheckUserRemember.executeQuery(); String errMsg="Error: "; if (rs.next()) { if ( checkDateDiff(now, rmbdT, daysRMB) &&Arrays.equals(rs.getBytes("auth"), unhex(rmbdAuth)) &&Arrays.equals(rs.getBytes("token"), unhex(rmbdToken)) &&rs.getString("log").equals(log) &&rs.getInt("sW")==Integer.parseInt(screenWidth) &&rs.getInt("sH")==Integer.parseInt(screenHeight) ) { byte[] session=randomBytes(32); byte[] token=randomBytes(32); ResultSet user=findUserByIndex(user_i); if (user!=null && user.next()) { setCookie=createUserSession(user.getLong("i"), now, session, token, ip); byte[] newToken=randomBytes(32); rs.updateString("tLast", now); rs.updateBytes("token", newToken); rs.updateRow(); setCookie+="\nSet-Cookie: rmbdToken="+hex(newToken)+cookieOptionRMBtoken; logs(user_i, now, ip, "rmb", true); return setCookie; } } else { // Failed: Delete rmbd cookie. if (!checkDateDiff(now, rmbdT, daysRMB)) { errMsg+="expired. "; } if (!Arrays.equals(rs.getBytes("auth"), unhex(rmbdAuth))) { errMsg+="auth. "; } if (!Arrays.equals(rs.getBytes("token"), unhex(rmbdToken))) { errMsg+="token. "; } if (!rs.getString("log").equals(log)) { errMsg+="log. "; } if (!(rs.getInt("sW")==Integer.parseInt(screenWidth))) { errMsg+="sW. "; } if (!(rs.getInt("sH")==Integer.parseInt(screenHeight))) { errMsg+="sH. "; } } } else { errMsg+="Not remembered."; } logs(user_i, now, ip, "rmb", false, errMsg); } catch (SQLException e) { err(e); }}} return setCookie; } ```/
## Log-out ### String logout() 모든 "session" and "remember me" 관련 cookie 들을 지움.
logout() ```[.linenums.scrollable.lang-java] public String logout() { String setCookie=""; setCookie="I="+cookieOptionDelSSN; // I=;max-age=-100;path=/;HttpOnly setCookie+="\nSet-Cookie: tCreate="+cookieOptionDelSSN; // tCreate=;max-age=-100;path=/;HttpOnly setCookie+="\nSet-Cookie: SSN="+cookieOptionDelSSN; // SSN=;max-age=-100;path=/;HttpOnly setCookie+="\nSet-Cookie: token="+cookieOptionDelSSN; // token=;max-age=-100;path=/;HttpOnly setCookie+="\nSet-Cookie: rmbdI="+cookieOptionDelRMB; // rmbdI=;max-age=-100;path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdT="+cookieOptionDelRMB; // rmbdT=;max-age=-100;path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdAuth="+cookieOptionDelRMB; // rmbdAuth=;max-age=-100;path=/account/log-in;HttpOnly setCookie+="\nSet-Cookie: rmbdToken="+cookieOptionDelRMB; // rmbdToken=;max-age=-100;path=/account/log-in;HttpOnly return setCookie; } ```/
## Keep connected : Session and Remembering me 한번 log-in 에 성공하고 나면, 그 연결상태를 유지하는게 중요한데... 이건 http-only cookie 를 사용하면 되는걸래나? 이 경우에도 통신 중간에 낑겨들어서 session 을 훔쳐서 로그인 한 척 하는거에 대한 방책이 딱히 없긴 한데... 한번 log-in 에 성공한 사용자를 기억하는 방법은? ## RRA
  1. recoeve.net