Ch11-3 : Login

[Login]
前面兩個小節介紹到登入需要的資料庫表單,以及相關檔案架構,這個章節會詳細說明,我們登入功能是如何思考與規劃,以及最後實作的部份。


[Think]
如果只是單純設計登入功能,那會是很容易的事情,但是如果需要實作出公用的登入檢查程式,套用在之後需要檢查的頁面時,這部份要深入思考合適的設計方式,才會讓程式朝向更好的發展。

這次我採用的設計方法是Aspect-Oriented Programming,它的設計理念就像是從旁去檢視的裁判一般,從旁去觀察,程式執行的動作,是否符合我們訂製好的規範。若發現有問題,可以直接干涉程式的運行,進行必要的處理。也可以用來單純觀察和紀錄程式的運行過程。

Aop技術本源是個設計模式的Proxy(代理模式)的應用,而相關參考資料,可以看Reference說明與解釋。

既然我們決定好使用Aop的方式打造登入功能,我們就必須去選擇要使用那一套JavaAop技術。而Java Aop比較常見的有aspectjAOP Alliance。而Spring-Aop有分別針對這兩個Aop程式提供很好的支援。

Play2.3.8之後開始把GlobalSettings Deprecated掉,其中getControllerInstance整個拔除掉,不能用原本Spring get BeanInstance我們的Controller,導致無法監聽我們的方法或類別,若要使用Aop技術,我們要改用新版Play所使用的GuiceAop來實作這個功能,而Gucie含有AOP Alliance的實作,為了程式容易實作出Aop功能,我們是會採用AOP AllianceSpring-Aop,來打造登入功能。


[Design]

在進行程式撰寫前,我們要先知道我們的Aop流程在Play上是怎麼運行的。我會從PlayLogin分別來說明。

Play
GuicePlay 2.4.0開始導入。而啟動Play的方式不再推薦使用GlobalSettings,而使改用Guice modules方式,讓您自訂要啟動的服務。而Play啟動之後,就會您把您定義好的modules,常駐Play上,當有需要使用到時,就會用Inject方式,去使用儲存好的服務。而Aop當然也可以用這種方式,常駐在Play身上,等待需要使用時再被呼叫及使用。

Login
登入本身是在正常不過的動作,而我們網站設計,需要設計出不需要每次重新登入的功能,我們需要儲存一些資訊在使用者的瀏覽器上,藉由這些資訊,來跟我們的Play cacheuser_session表單裡作為身分比對的依據,檢查是否符合我們網站的身分檢查。以下擷取至AuthBlocker.java的註解部份。後面程式邏輯,會依據這些流程告知目前使用者是否含有Cookie進行各項檢查。檢視使用者是否可以進行登入動作。

Step 1     : 檢查使用者Cookie是否有資料

Step 1.1   : 沒有Cookie直接跳到Step2

Step 1.2   : 有Cookie ,檢查cache是否有資料

Step 1.2.1 : cache 有資料,根據sessionId , 查詢我們 cache , 解密sessionSign是否正確,是否逾期
             沒通過 => 清除使用者Cookie,重新Step2登入動作 (1.2.2.2)
             通過   => 24小時內更新過,不需要更新,直接登入 
                   => 24小時內尚未更新過,更新並延長期限
                      Session table, login log, server cache, bowser cookie

Step 1.2.2 : cache 沒資料,進行查詢Session表單, 是否正確,是否逾期
            沒資料   => 清除使用者Cookie,重新Step2登入動作 (1.2.2.1)
             沒通過  => 清除使用者Cookie,重新Step2登入動作 (1.2.2.2)
            通過     => 24小時內更新過,不需要更新,直接登入 
                    => 24小時內尚未更新過,更新並延長期限
                     Session table, login log, server cache, bowser cookie


Step 2     : 普通一般登入步驟

Step 2.1   : 檢察登入資訊是否符合格式
             不符合表單 => 顯示提示訊息

Step 2.2   : 檢查是否有該會員資料
             無會員資料  => 畫面顯示無註冊資料

Step 2.3   : 有會員資料,認證尚未通過,停權,或密碼錯誤
             => 顯示認證尚未通過,或停權,或密碼錯誤

Step 2.4   : 通過以上檢查
            => 新增 Session table, login log, server cache, bowser cookie

Note : 停權時,要特別注意,要刪除掉cache與session表單的登入資料,以免錯誤


接下來我會依序幾個大項,開始新增相關程式。

  • Step 1 : Page , 新增網頁畫面。
  • Step 2 : pojo and services , 新增相關的pojoservices
  • Step 3 : Aop , 新增相關Aop程式。
  • Step 4 : Setting Controller and Routes , 新增對應的Controllerroutes設定。
  • Step 5 : Test Case , 進行案例測試。

[Implementation]

Step 1 : Page
app.views.web.loginSignup.login.scala.html
首先我們新增網頁畫面。

<!DOCTYPE html>
<html >
  <head>
    <meta charset="UTF-8">
    <title>登入</title>

    @views.html.web.headerLibs()

    @views.html.web.loginSignup.loginSignupLibs()


  </head>

  <body>
    <div id="page-wrapper">
        <div id="select_nav_user">@views.html.web.headerNav()</div>
    </div>
    <div class="form">

      <ul class="tab-group">
        <li class="tab active"><a href="#">登入</a></li>
      </ul>
      <div class="tab-content">
        <div id="login" >   

          <form action="@controllers.routes.WebController.login.url" method="post" id="loginForm">
            <div class="field-wrap">
                <label class="lable-field-wrap">電子信箱</label>
                <input type="email" required autocomplete="off" name="email"/>
            </div>
            <div class="field-wrap">
                <label class="lable-field-wrap">密碼</label>
                <input type="password" required autocomplete="off" name="password"/>
                @if(flash.containsKey("errorLogin")) {
                    <span style="color:red;">@flash.get("errorLogin")</span>
                }
            </div>
            <input type="hidden" name="role" value="MEMBER"/>
             <ul>
                <li class="home"><a href="@controllers.routes.WebController.index.url">回首頁</a></li>
                <li class="forgot"><a href="#forgot">忘記密碼?</a></li>
             </ul>
            <a>
                <input type="submit" value="Login" class="button button-block">
            </a>
            </form>
        </div>
        </div><!-- tab-content -->   
    </div> <!-- /form -->
    <div id="titleBar"></div>
    <script src='@routes.Assets.versioned("javascripts/loginSignup.js")'></script>
    <script>
        $( document ).ready(function() {
            document.getElementById("loginForm").reset();
        });
    </script>
  </body>
</html>



Step 2 : pojo and services
新增相關的pojoservices

app
└pojo
   └web
     └request
        └AuthRequest.java   <--- 會員登入請求
     └ServerCache.java      <--- 伺服器暫存資料
       UserCookie.java       <--- 瀏覽器使用者的Cookie資料
       UserRole.java         <--- 登入的使用者角色類型
       UserSession.java      <--- 對應到表單紀錄的Session資料
└services
  └Impl
     └WebServiceImpl.java   <--- 實做WebService的新功能
  └WebService.java          <--- 新增UserSession的新增資料與查詢功能


app.pojo.web.request.AuthRequest.java
會員登入請求。有三個屬性,使用者要登入的時候,要輸入當初註冊的信箱與密碼,而role在會員登入中,會在網頁的input hidden欄位寫死MEMBER登入,為了避免被修改,而後端也會特別針對role欄位進行檢核。

package pojo.web.auth.request;

public class AuthRequest {

  private String email;

  private String password;

  private String role;

  public void setEmail(String email) {
    this.email = email;
  }

  public void setPassword(String password) {
    this.password = password;
  }

  public String getEmail() {
    return email;
  }

  public String getPassword() {
    return password;
  }

  public String getRole() {
    return role;
  }

  public void setRole(String role) {
    this.role = role;
  }

}


app.pojo.web.auth.ServerCache.java
伺服器暫存的會員登入資料,我們會使用這個物件,方便我們使用Jackson把物件轉換成Json String

package pojo.web.auth;

public class ServerCache{
  // 加簽的會員資料
  private String sessionSign;
  // cookie 逾期日期
  private String expiryDate;
  // 隨機產生加密KEY
  private String aseKey;
  // 隨機產生加密IV
  private String aseIv;

  public String getSessionSign() {
    return sessionSign;
  }
  public void setSessionSign(String sessionSign) {
    this.sessionSign = sessionSign;
  }
  public String getExpiryDate() {
    return expiryDate;
  }
  public void setExpiryDate(String expiryDate) {
    this.expiryDate = expiryDate;
  }
  public String getAseKey() {
    return aseKey;
  }
  public void setAseKey(String aseKey) {
    this.aseKey = aseKey;
  }
  public String getAseIv() {
    return aseIv;
  }
  public void setAseIv(String aseIv) {
    this.aseIv = aseIv;
  }
}


app.pojo.web.auth.UserCookie.java
瀏覽器使用者的Cookie資料,方便我們使用Jackson把物件轉換成Json String

package pojo.web.auth;

public class UserCookie {
  // 會員編號
  private String no;
  // 到期日
  private String expiryDate;
  // 登入角色類型
  private String role;

  public String getNo() {
    return no;
  }
  public void setNo(String no) {
    this.no = no;
  }
  public String getRole() {
    return role;
  }
  public void setRole(String role) {
    this.role = role;
  }
  public String getExpiryDate() {
    return expiryDate;
  }
  public void setExpiryDate(String expiryDate) {
    this.expiryDate = expiryDate;
  }
}


app.pojo.web.auth.UserRole.java
登入的使用者角色類型,為了區分之後可能的登入身分,先行規劃出登入角色區別。

package pojo.web.auth;

/**使用者身份類型*/
public enum UserRole {
  // 會員
  MEMBER,
  // 員工
  EMPLOYEE,
  // 管理者
  ADMINISTER
}


app.pojo.web.auth.UserSession.java
對應到表單紀錄的user_session資料。這個表單詳盡紀錄使用者登入的相關資訊。

package pojo.web.auth;

public class UserSession {
  /** Session 編號,Key值*/
  private String sessionId;  
  /** 加簽過資料*/
  private String sessionSign;
  /** 隨機產生的Key值*/
  private String aseKey;
  /** 隨機產生的IV值*/
  private String aseIv;
  /** 編號*/
  private String no;
  /**登入角色*/
  private String role;
  /** 逾期日期*/
  private String expiryDate;
  /** 建立日期*/
  private String createDate;
  /** 修改日期*/
  private String modifyDate;
  public String getSessionId() {
    return sessionId;
  }
  public void setSessionId(String sessionId) {
    this.sessionId = sessionId;
  }
  public String getSessionSign() {
    return sessionSign;
  }
  public void setSessionSign(String sessionSign) {
    this.sessionSign = sessionSign;
  }
  public String getAseKey() {
    return aseKey;
  }
  public void setAseKey(String aseKey) {
    this.aseKey = aseKey;
  }
  public String getAseIv() {
    return aseIv;
  }
  public void setAseIv(String aseIv) {
    this.aseIv = aseIv;
  }
  public String getNo() {
    return no;
  }
  public void setNo(String no) {
    this.no = no;
  }
  public String getRole() {
    return role;
  }
  public void setRole(String role) {
    this.role = role;
  }
  public String getExpiryDate() {
    return expiryDate;
  }
  public void setExpiryDate(String expiryDate) {
    this.expiryDate = expiryDate;
  }
  public String getCreateDate() {
    return createDate;
  }
  public void setCreateDate(String createDate) {
    this.createDate = createDate;
  }
  public String getModifyDate() {
    return modifyDate;
  }
  public void setModifyDate(String modifyDate) {
    this.modifyDate = modifyDate;
  }
}


app.services.WebService.java
調整我們的WebService.java,新增寫入user_session與查詢功能。

...
  /** 新增會員Session資料 */
  public int genUserSession(@Param("userSession") UserSession userSession);

  /** 取得會員Session資料*/
  public UserSession getUserSession(String sessionId);
...


app.services.Impl.WebServiceImpl.java WebServiceImpl.java,實作WebService的新功能。

...
  public int genUserSession(UserSession userSession) {
    return this.webService.genUserSession(userSession);
  }

  public UserSession getUserSession(String sessionId) {
    return this.webService.getUserSession(sessionId);
  }
...


conf.services.WebService.xml
根據Ch11-1小節所設計的user_session表單,新增對應的SQL新增與查詢語法。
note : 特別注意到insert指令帶有ON DUPLICATE KEY UPDATE,這個指令用途是MySQL使用的,當Insert資料時,如果發現資料重覆時,不會執行失敗,而改用執行更新指令方式,進行表單更新。

    <!-- 會員Session紀錄檔 -->
    <insert id="genUserSession" parameterType="pojo.web.auth.UserSession">
        insert into
        user_session(sessionId , sessionSign , aseKey , aseIv , no , role , expiryDate , createDate , modifyDate)
        values
        ( 
            #{userSession.sessionId} , 
            #{userSession.sessionSign} ,
            #{userSession.aseKey} ,
            #{userSession.aseIv} ,
            #{userSession.no} ,
            #{userSession.role} ,
            #{userSession.expiryDate} ,
            #{userSession.createDate} ,
            #{userSession.modifyDate} 
        ) ON DUPLICATE KEY UPDATE 
        sessionId     = #{userSession.sessionId},
        sessionSign = #{userSession.sessionSign},
        aseKey         = #{userSession.aseKey},
        aseIv         = #{userSession.aseIv},
        expiryDate     = #{userSession.expiryDate},
        modifyDate     = #{userSession.modifyDate}
    </insert>


     <!-- 撈出會員Session資料 -->
    <select id="getUserSession" parameterType="String" resultType="pojo.web.auth.UserSession">
        SELECT * FROM user_session WHERE sessionId = #{sessionId}
    </select>

Step 3 : Aop
新增相關Aop程式。

app
└annotation
  └AuthCheck.java               <---Java註解,可以把它在方法身上,執行登入檢查
└aop
  └advice
    └BeforeAndAfterAdvice.java  <---共用的Before與After 的Advice
  └AfterBlocker.java             <---執行後動作
    AuthBlocker.java              <---執行檢查
    BeforeBlocker.java            <---執行前動作
    CommonBlocker.java            <---共用Aop套件程式
└modules
  └AopModule.java                <---Aop的Module,讓Play一執行時,可以讓我們Aop代理程式常駐
└utils
  └Utils_Session.java             <---公用Session程式,處理一些Session整理與轉換
└enc
  └AESEncrypter.java             <---ASE加密程式


app.annotation.AuthCheck.java
在實作Aop時,我們需要新增一個annotation來讓Play知道當出現這個註解時,執行對應的檢查。

package annotation;

import java.lang.annotation.Retention;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface AuthCheck {
}


aop.advice.BeforeAndAfterAdvice.java
共用的BeforeAfterAdviceSpring-Aop特別針對AOP Alliance,新增更多的Advice功能,而Play可以完整支援到SpringAop部份。這個方法我用來簡單紀錄方法進來時與結束的部份,還有計算方法結束後耗時了多久。

package aop.advice;

import java.lang.reflect.Method;
import java.text.Format;
import java.text.SimpleDateFormat;
import java.util.Date;

import org.springframework.aop.AfterReturningAdvice;
import org.springframework.aop.MethodBeforeAdvice;

public class BeforeAndAfterAdvice implements MethodBeforeAdvice , AfterReturningAdvice {

  long startTime;
  long endTime;
  Format format = new SimpleDateFormat("YYYY/MM/dd HH:mm:ss");

  @Override
  public void before(Method method, Object[] args, Object target) throws Throwable {
    startTime = System.currentTimeMillis();
    String name = method.getName();
    play.Logger.info("before target = "+ target +",method = "+ name 
                     +" ,start time = " + formatTime(startTime));
  }

  @Override
  public void afterReturning(Object returnValue, Method method, Object[] args, Object target)
      throws Throwable {
    endTime = System.currentTimeMillis();
    String name = method.getName();
    play.Logger.info("after target = "+ target +",method = "+ name 
                      + ", end time = " + formatTime(endTime)
                      +" , cost time = " + (endTime - startTime) + "ms" );
  }

  public String formatTime(long time){
    return format.format(new Date(time));
  }

}


在寫好Advice之後,我們需要寫好相對應的MethodInterceptor。因為Spring-Aop改善原有Aop AllianceMethodInterceptor,新增了MethodBeforeAdviceInterceptorAfterReturningAdviceInterceptor讓我們的程式,可以在執行前與執行後,進行更為詳細的控制。

app/aop/BeforeBlocker.java

package aop;


import org.springframework.aop.MethodBeforeAdvice;
import org.springframework.aop.framework.adapter.MethodBeforeAdviceInterceptor;

public class BeforeBlocker extends MethodBeforeAdviceInterceptor{

  private static final long serialVersionUID = 1L;

  public BeforeBlocker(MethodBeforeAdvice advice) {
    super(advice);
  }  
}


app/aop/AfterBlocker.java

package aop;


import org.springframework.aop.AfterReturningAdvice;
import org.springframework.aop.framework.adapter.AfterReturningAdviceInterceptor;

public class AfterBlocker extends AfterReturningAdviceInterceptor{

  private static final long serialVersionUID = 1L;

  public AfterBlocker(AfterReturningAdvice advice) {
    super(advice);
  }

}


app.aop.CommonBlocker.java
這個類別是同時繼承play.mvc.Controller與實作MethodInterceptor,為了準備給AuthBlocker.java所使用,因為當程式方法被MethodInterceptor所捕捉到時,為了讓程式後續可以使用到Play已經Inject的物件,而Play在執行期間時,可以使用play.api.Play.current().injector(),去取得目前啟動的服務,就可以避免掉重覆Inject的問題。

package aop;


import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

import play.cache.DefaultCacheApi;
import play.data.FormFactory;
import play.mvc.Controller;
import services.WebService;


/**
 * <pre>
 *  Super Class
 *  該類別用途是當方法進來時
 *  去取得目前Play current injector的相關服務
 *  WebService       (modules.MyBatisModule Inject WebService)
 *  FormFactory      (Play Inject FormFactory)
 *  DefaultCacheApi  (Play Inject DefaultCacheApi)
 * </pre>
 */
public class CommonBlocker extends Controller implements MethodInterceptor{

  protected WebService webService;
  protected FormFactory formFactory;
  protected DefaultCacheApi cache;

  private static play.api.inject.Injector injector() {
    return play.api.Play.current().injector();
  }

  @Override
  public Object invoke(MethodInvocation invocation) throws Throwable {
    play.Logger.info("CommonBlocker get Play current Inject Class");
    this.webService = injector().instanceOf(WebService.class);
    this.formFactory = injector().instanceOf(FormFactory.class);
    this.cache = injector().instanceOf(DefaultCacheApi.class);
    play.Logger.info("Joinpoint   = " + invocation.getThis().getClass());
    play.Logger.info("getClass    = " + invocation.getClass());
    play.Logger.info("method      = " + invocation.getMethod());
    play.Logger.info("arguments   = " + invocation.getArguments());
    play.Logger.info("webService  = " + webService);
    play.Logger.info("formFactory = " + formFactory);
    play.Logger.info("cache       = " + cache);
    return invocation;
  }

}


app.utils.enc.AESEncrypter.java
這是用使用別人寫好的ASE加密程式,我也稍微進行了一些微調之後所完成的。我們的Session加密技術是採用ASE對稱式加解密法,我們隨機產生一組KeyIV,跟我們的Json字串,進行加解密動作,確保我們儲存的cookieSession資料不容易被破解且使用。而相關的ASE加密技術,可以在Referece的文章,進行更詳盡的了解。該隻程式留有main方法可以本機測試執行觀看執行結果。

package utils.enc;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import play.libs.Json;
import pojo.web.auth.ServerCache;

import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

/**
 * <pre>
 * 
 * 利用隨機產生Key和IV ,進行AES加解密動作
 * 
 * Reference 
 *  1.http://stackoverflow.com/questions/41107/how-to-generate-a-random-alpha-numeric-string
 *  2.http://stackoverflow.com/questions/15554296/simple-java-aes-encrypt-decrypt-example/22445878#22445878
 * </pre>
 */
public class AESEncrypter {

  public static String encrypt(String key, String initVector, String value) {
    try {
      IvParameterSpec iv = new IvParameterSpec(initVector.getBytes("UTF-8"));
      SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "AES");

      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
      cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);

      byte[] encrypted = cipher.doFinal(value.getBytes());
      String encResult = Base64.getEncoder().encodeToString(encrypted);
      System.out.println("encrypted string : " + encResult);

      return encResult;
    } catch (Exception ex) {
      ex.printStackTrace();
    }
    return null;
  }

  public static String decrypt(String key, String initVector, String encrypted) {
    try {
      IvParameterSpec iv = new IvParameterSpec(initVector.getBytes("UTF-8"));
      SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "AES");

      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
      cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);

      byte[] original = cipher.doFinal(Base64.getDecoder().decode(encrypted));
      String decResult = new String(original);

      System.out.println("decrypt result string : " + decResult);

      return decResult;
    } catch (Exception ex) {
      ex.printStackTrace();
    }

    return null;
  }

  public static String randomString(int len) {
    final String charSet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    SecureRandom rnd = new SecureRandom();
    StringBuilder sb = new StringBuilder(len);
    for (int i = 0; i < len; i++) {
      sb.append(charSet.charAt(rnd.nextInt(charSet.length())));
    }
    return sb.toString();
  }

  public static void main(String[] args) {

    String clientSessionId = java.util.UUID.randomUUID().toString();
    String key = randomString(16);  // 128 bit key
    String iv = randomString(16);   // 16 bytes IV
    String clientSessionUnsign = "{\"no\":\"mem000000000001\",\"role\":\"MEMBER\",\"expiryDate\":\"20161020120000\"}";

    String clientSessionSign = encrypt(key, iv, clientSessionUnsign);
    String decodeString  = decrypt(key, iv, clientSessionSign);  

    System.out.println("------------------------------------------------");
    System.out.println("Target          => " + clientSessionUnsign);
    System.out.println("key             => " + key);
    System.out.println("iv              => " + iv);
    System.out.println("encryptString   => " + clientSessionSign);
    System.out.println("decodeString    => " + decodeString);
    System.out.println("------------------------------------------------");

    Map<String , String> sessionId = new HashMap<String, String>();
    sessionId.put("sessionId", clientSessionId);


    Map<String , String> sessionSign = new HashMap<String, String>();
    sessionSign.put("sessionSign", clientSessionSign);


    System.out.println("client SessionId      => " + Json.toJson(sessionId));
    System.out.println("client SessionSign    => " + Json.toJson(sessionSign));
    System.out.println("------------------------------------------------");

    ServerCache data = new ServerCache();
    data.setExpiryDate("20161020120000");
    data.setAseKey(key);
    data.setAseIv(iv);
    data.setSessionSign(clientSessionSign);
    Map<String , ServerCache> serverCacheData = new HashMap<String , ServerCache>();
    serverCacheData.put(clientSessionId, data);
    System.out.println("server Cache = " + Json.toJson(serverCacheData));
  }   
}


app.utils.session.Utils_Session.java
我們處理Session部份,特別抽離出來獨立成為一個類別,去處理之後我們進行各種登入資訊,與需要執行的動作。

package utils.session;


import java.text.Format;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

import com.fasterxml.jackson.databind.JsonNode;

import play.cache.CacheApi;
import play.cache.DefaultCacheApi;
import play.libs.Json;
import play.mvc.Http;
import play.mvc.Http.Cookie;
import play.mvc.Http.Request;
import pojo.web.Member;
import pojo.web.auth.UserCookie;
import pojo.web.auth.UserSession;
import pojo.web.auth.ServerCache;
import utils.enc.AESEncrypter;

public class Utils_Session {

  /** Cookie and Session 設定14天的存活時間*/
  public final int maxAge =  (60 * 60 * 24) * 14;

  /**Cookie and Session for Java Date getTime use*/
  public final long maxAgeLong =  (60 * 60 * 24) * 14 * 1000;

  /** 
   * <pre>
   * 傳入查詢出來的會員資料,產生會員Session資料
   * @param member
   * @return userSession
   * </pre>
   */
  public UserSession genUserSession(String no , String role){

    AESEncrypter aes = new AESEncrypter();
    String clientSessionId = java.util.UUID.randomUUID().toString();
    Format formatter = new SimpleDateFormat("yyyyMMddHHmmss");
    String expiryDate = formatter.format(new Date(new Date().getTime() + maxAgeLong ));
    String createDate = formatter.format(new Date());
    UserCookie userSessionUnsign = new UserCookie();
    userSessionUnsign.setNo(no);
    userSessionUnsign.setRole(role);
    userSessionUnsign.setExpiryDate(expiryDate);
    String strClientSessionUnsign = Json.toJson(userSessionUnsign).toString();
    String aseKey = aes.randomString(16);
    String aseIv = aes.randomString(16);
    String strClieantSessionSign = aes.encrypt(aseKey, aseIv, strClientSessionUnsign);

    UserSession userSession = new UserSession();
    userSession.setSessionId(clientSessionId);
    userSession.setSessionSign(strClieantSessionSign);
    userSession.setAseKey(aseKey);
    userSession.setAseIv(aseIv);
    userSession.setNo(no);
    userSession.setRole(role);
    userSession.setExpiryDate(expiryDate);
    userSession.setCreateDate(createDate);
    userSession.setModifyDate(createDate);
    return userSession;
  }

  /**
   * <pre>
   * 登入之後,設定瀏覽器Cookie與伺服的Cache,儲存會員登入資訊
   * @param response , play response
   * @param cache  , server Cache
   * @param userSession , 會員Session表
   * @param maxAge , 設定存活時間
   * @param path , 使用路徑範圍(本機設定空字串)
   * @param domain , 網域(本機設定空字串)
   * @param secure , 是否是Https網站
   *</pre>
   */
  public void setMemberCookieAndCache(Http.Response response , CacheApi cache , UserSession userSession , int maxAge ,String path , String domain , boolean secure ){
    this.setCache(cache, userSession , maxAge);
    this.setCookie(response , userSession , maxAge , path , domain , secure);
  }

  /**
   *寫入Server Cache 
   */
  public void setCache(CacheApi cache , UserSession userSession , int expiration){
    ServerCache data = new ServerCache();
    data.setExpiryDate(userSession.getExpiryDate());
    data.setAseKey(userSession.getAseKey());
    data.setAseIv(userSession.getAseIv());
    data.setSessionSign(userSession.getSessionSign());
    cache.set(userSession.getSessionId(), Json.toJson(data), expiration);
  }


  /**
   * 寫入使用者瀏覽器Cookie 
   */
  public void setCookie(Http.Response response  ,UserSession userSession , int maxAge ,String path , String domain , boolean secure ){
    response.setCookie(new Cookie("sessionId" , userSession.getSessionId() , maxAge , path , domain , secure , true));
    response.setCookie(new Cookie("sessionSign" , userSession.getSessionSign() , maxAge , path , domain , secure , true));
  }


  /** 比對 server cache 與 cookie session 是否一致*/
  public boolean isCookieSameAsCacheData(CacheApi cache , String sessionId , String sessionSign){
    if("".equals(sessionId) || "".equals(sessionSign)){
      return false;
    }
    return sessionSign.equals(cache.get(sessionId));
  }


  /** 檢查是否有我們的session*/
  public boolean isClinetHaveCookie(Http.Request request){
    return request.cookies().get("sessionId")!=null && 
           request.cookies().get("sessionSign")!=null &&
           !"".equals(request.cookies().get("sessionId").value()) && 
           !"".equals(request.cookies().get("sessionSign").value());
  }


  /** 取得使用者瀏覽器的Cookie 加簽資料*/
  public String getClientSession(Request request) {
    if(request.cookies().get("sessionId")==null){
      return "";
    }
    return request.cookies().get("sessionId").value();
  }

  /** 取得使用者瀏覽器的Cookie 加簽資料*/
  public String getClientSessionSign(Request request) {
    if(request.cookies().get("sessionSign")==null){
      return "";
    }
    return request.cookies().get("sessionSign").value();
  }

  /** 伺服器是否使用者的cookie資料*/
  public boolean isCacheHaveThisSession(DefaultCacheApi cache, String sessionId) {
    play.Logger.info("cache get sessionId = " + cache.get(sessionId));
    return cache.get(sessionId) != null && !"".equals(cache.get(sessionId));
  }


  /**取得目前伺服器的Cache資料*/
  public UserSession getServerCacheData(DefaultCacheApi cache, String sessionId) {
    try{
      UserSession userSession = new UserSession();
      JsonNode sessionNode = Json.parse(cache.get(sessionId).toString());
      userSession.setAseIv(sessionNode.get("aseIv").textValue());
      userSession.setAseKey(sessionNode.get("aseKey").textValue());
      userSession.setSessionId(sessionId);
      userSession.setSessionSign(sessionNode.get("sessionSign").textValue());
      AESEncrypter aes = new AESEncrypter();
      JsonNode rawData = Json.parse(aes.decrypt(userSession.getAseKey(), 
                                                userSession.getAseIv(), 
                                                userSession.getSessionSign()).toString());
      userSession.setNo(rawData.get("no").textValue());
      userSession.setRole(rawData.get("role").textValue());
      userSession.setExpiryDate(rawData.get("expiryDate").textValue());
      return userSession;
    } catch(Exception e){
      e.printStackTrace();
      return null;
    }
  }


  /**清除瀏覽器Cookie*/
  public void clearClientCookie(Http.Response response){
    response.discardCookie("sessionId");
    response.discardCookie("sessionSign");
  }


  /** 
   * 檢查是否超過24小時 
   * note : 目前預設cookie 14天,只要expiryDate小於13天,代表超過一天沒更新
   */
  public boolean isRewriteCookie(String expiryDate){
    boolean isRewrite = true;
    try {
      SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMddHHmmss");
      Date expiryDateTime = formatter.parse(expiryDate);
      String nowString = formatter.format(new Date());
      Date nowTime = formatter.parse(nowString);
      Date diff = new Date(expiryDateTime.getTime()-nowTime.getTime());
      long betweentDate = diff.getTime() / (60 * 60 * 24 * 1000);
      if(betweentDate >= 13){
        isRewrite = false; 
      }
      play.Logger.info("expiryDate      = " + expiryDateTime.getTime());
      play.Logger.info("now             = " + nowTime.getTime());
      play.Logger.info("betweentDate    = " + betweentDate + "day");
      play.Logger.info("isRewrite  = " + isRewrite);
    } catch (ParseException e) {
      e.printStackTrace();
    }    
    return isRewrite ;
  }

}


app.aop.AuthBlocker.java
以上所有類別的準備,都是為了這隻登入檢驗程式。AuthBlocker.java本身繼承了共用CommonBlocker.java,所以AuthBlocker是本身是屬於一個MethodInterceptor的類別,當我們使用到Annotation.Authcheck時,該方法就會進入這隻程式,進行各種登入的驗證與檢核,而為了之後身份檢核動作不頻繁查詢資料庫,也增加了Server Cache機制去暫存登入資訊。而詳細的驗證步驟,可以查看以下的程式註解,會更了解實際登入驗證流程怎麼實作的。

package aop;

import java.util.Map;

import org.aopalliance.intercept.MethodInvocation;

import play.Logger;
import play.libs.Json;
import play.mvc.Result;
import pojo.web.Member;
import pojo.web.MemberLoginStatus;
import pojo.web.MemberStatus;
import pojo.web.auth.UserRole;
import pojo.web.auth.UserSession;
import pojo.web.auth.request.AuthRequest;
import utils.session.Utils_Session;
import utils.signup.Utils_Signup;

public class AuthBlocker extends CommonBlocker{

  /**
   * <pre>
   * Step 1     : 檢查使用者Cookie是否有資料
   * 
   * Step 1.1   : 沒有Cookie直接跳到Step2
   * 
   * Step 1.2   : 有Cookie ,檢查cache是否有資料
   * 
   * Step 1.2.1 : cache 有資料,根據sessionId , 查詢我們 cache , 解密sessionSign是否正確,是否逾期
   *              沒通過 => 清除使用者Cookie,重新Step2登入動作 (1.2.2.2)
   *              通過    => 24小時內更新過,不需要更新,直接登入 
   *                    => 24小時內尚未更新過,更新並延長期限
   *                       Session table, login log, server cache, bowser cookie
   * 
   * Step 1.2.2 : cache 沒資料,進行查詢Session表單, 是否正確,是否逾期
   *              沒資料   => 清除使用者Cookie,重新Step2登入動作 (1.2.2.1)
   *              沒通過   => 清除使用者Cookie,重新Step2登入動作 (1.2.2.2)
   *              通過       => 24小時內更新過,不需要更新,直接登入 
   *                     => 24小時內尚未更新過,更新並延長期限
   *                       Session table, login log, server cache, bowser cookie
   *          
   *                     
   * Step 2     : 普通一般登入步驟
   * 
   * Step 2.1   : 檢察登入資訊是否符合格式
   *              不符合表單 => 顯示提示訊息
   *              
   * Step 2.2   : 檢查是否有該會員資料
   *              無會員資料  => 畫面顯示無註冊資料
   *
   * Step 2.3   : 有會員資料,認證尚未通過,停權,或密碼錯誤
   *              => 顯示認證尚未通過,或停權,或密碼錯誤
   *              
   * Step 2.4   : 通過以上檢查
   *              => 新增 Session table, login log, server cache, bowser cookie
   *
   * Note : 停權時,要特別注意,要刪除掉cache與session表單的登入資料,以免錯誤
   *</pre>
   */
  @Override
  public Object invoke(MethodInvocation invocation) throws Throwable {
    super.invoke(invocation);
    flash().clear();

    // Step 1
    Utils_Session utilsSession = new Utils_Session(); 
    boolean isClientHaveCookie = utilsSession.isClinetHaveCookie(request());
    play.Logger.info("isClientHaveCookie = " + isClientHaveCookie);

    // Step 1.2
    if(isClientHaveCookie){
      String clientSessionId = utilsSession.getClientSession(request());
      String clientSessionSign = utilsSession.getClientSessionSign(request());
      boolean isCacheHaveThisSession = utilsSession.isCacheHaveThisSession(cache,clientSessionId);

      play.Logger.info("clientSessionId   = " + clientSessionId);
      play.Logger.info("clientSessionSign = " + clientSessionSign);
      play.Logger.info("isCacheHaveThisSession = " + isCacheHaveThisSession);

      // Step 1.2.1
      if(isCacheHaveThisSession){

        UserSession serverCacheUserSession = utilsSession.getServerCacheData(cache,clientSessionId);

        // 沒通過 
        if(!clientSessionSign.equals(serverCacheUserSession.getSessionSign())){
          utilsSession.clearClientCookie(response());
          flash().put("errorLogin", "請先執行登入動作,謝謝!(0x1.2.1.1)");
          play.Logger.warn("Step 1.2.1 : cache 有資料,但與使用者Cookie比對不符,沒通過檢查。");
          return redirect(controllers.routes.WebController.login().url());
        } 

        play.Logger.info("origin server cache userSession = " + Json.toJson(serverCacheUserSession));

        // 通過,超過24小時
        if(utilsSession.isRewriteCookie(serverCacheUserSession.getExpiryDate())){
          play.Logger.info("cache有資料比對通過,但超過24小時未更新Cookie,進行更新");
          try{
            this.cache.remove(clientSessionId);
            writeMemberCookieAndSession( utilsSession, serverCacheUserSession.getNo(),serverCacheUserSession.getRole());
          } catch (Exception e){
            e.printStackTrace();
            flash().put("errorLogin", "系統忙碌中,請稍後再嘗試!");
            return redirect(controllers.routes.WebController.login().url());
          }
        } else {
          play.Logger.info("cache有資料比對通過,Cookie尚在24小時內,不需要更新");
        }

        return invocation.proceed();

      } else {
        // Step 1.2.2
        UserSession dbUserSession = this.getUserSession(clientSessionId);

        // 沒有資料
        if(dbUserSession == null){
          utilsSession.clearClientCookie(response());
          flash().put("errorLogin", "請先執行登入動作,謝謝!(0x1.2.2.1)");
          play.Logger.warn("Step 1.2.2.1 : db沒資料,無法比對使用者資料。");
          return redirect(controllers.routes.WebController.login().url());
        }

        // 沒通過
        if(!clientSessionSign.equals(dbUserSession.getSessionSign())){
          utilsSession.clearClientCookie(response());
          flash().put("errorLogin", "請先執行登入動作,謝謝!(0x1.2.2.2)");
          play.Logger.warn("Step 1.2.2.2 : db有資料,但與使用者Cookie比對不符,沒通過檢查。");
          return redirect(controllers.routes.WebController.login().url());
        }

        play.Logger.info("origin db userSession = " + Json.toJson(dbUserSession));

        // 通過,超過24小時
        if(utilsSession.isRewriteCookie(dbUserSession.getExpiryDate())){
          play.Logger.info("db有資料比對通過,但超過24小時未更新Cookie,進行更新");
          try{
            dbUserSession.getNo();
            writeMemberCookieAndSession( utilsSession, dbUserSession.getNo() , dbUserSession.getRole());
          } catch (Exception e){
            e.printStackTrace();
            flash().put("errorLogin", "系統忙碌中,請稍後再嘗試!");
            return redirect(controllers.routes.WebController.login().url());
          }
        } else {
          play.Logger.info("db有資料比對通過,Cookie尚在24小時內,不需要更新");
        }

        return invocation.proceed();
      }
    } else {
      // Step 1.1 to Next Step2
    }


    // Step 2 
    AuthRequest request = this.getAuthRequest();

    // Step 2.1
    if(request == null){
      flash().put("errorLogin", "請先執行登入動作,謝謝!(0x2.1)");
      return redirect(controllers.routes.WebController.login().url());
    }

    // Step 2.2
    String email = request.getEmail();
    String password = request.getPassword();
    String role = request.getRole();
    boolean isMember = false;

    try{
      isMember = webService.checkMemberByEmail(email);
    } catch(Exception e){
      e.printStackTrace();
      flash().put("errorLogin", "系統忙碌中,請稍後再嘗試!");
      return redirect(controllers.routes.WebController.login().url());
    }

    if(!isMember){
      flash().put("errorLogin", "您尚未註冊成為會員!(0x2.2)");
      return redirect(controllers.routes.WebController.login().url());
    }

    // Step 2.3
    Member member = null;
    try {
      member = webService.findMemberByEmail(email);
      play.Logger.info("email = " + email + ", password = " + password  + ", role = " + role
                       + ", member Status = " + member.getStatus() 
                       + ", db password = " +  member.getPassword());
    }catch (Exception e){
      e.printStackTrace();
      flash().put("errorLogin", "系統忙碌中,請稍後再嘗試!");
      return redirect(controllers.routes.WebController.login().url());
    }

    if(MemberStatus.S1.getStatus().equals(member.getStatus())){
      flash().put("errorLogin", "您的帳號尚未認證成功!(0x2.3)");
      return redirect(controllers.routes.WebController.login().url());
    }

    if(MemberStatus.S3.getStatus().equals(member.getStatus())){
      flash().put("errorLogin", "您的帳號已被停權!(0x2.3)");
      return redirect(controllers.routes.WebController.login().url());
    }

    if(!password.equals(member.getPassword())){
      flash().put("errorLogin", "密碼錯誤,請再次確認密碼是否正確(0x2.3)");
      return redirect(controllers.routes.WebController.login().url());
    }

    // Step 2.4
    try{
      writeMemberCookieAndSession(utilsSession,member.getMemberNo() ,role);
    } catch (Exception e){
      e.printStackTrace();
      flash().put("errorLogin", "系統忙碌中,請稍後再嘗試!");
      return redirect(controllers.routes.WebController.login().url());
    }

    return (Result) invocation.proceed();
  }

  /** 
   * <pre>
   * 共用
   * 寫入Member Session Table 
   * 寫入Member Login log Table
   * 寫入Server Cache 與 Client cookie
   *</pre>
   */
  private void writeMemberCookieAndSession(Utils_Session utilsSession,String no , String role){
    // 寫入Member Session Table 
    UserSession userSession = utilsSession.genUserSession(no , role);
    int isUserSessionOk = this.webService.genUserSession(userSession);  

    // 寫入Member Login log Table
    Utils_Signup utilsSignup = new Utils_Signup();
    Map<String , String> memberLoginLogData      = utilsSignup.genMemberLoginData(no ,
                                      "PC" , 
                                      request().remoteAddress() , 
                                      MemberLoginStatus.S2.getStatus());
    int isMemberLoginLogOk = this.webService.genMemberLoginLog(memberLoginLogData);

    // 寫入Server Cache 與 Client cookie
    utilsSession.setMemberCookieAndCache(response(), cache, userSession, utilsSession.maxAge, "", "", false);
    play.Logger.info("isMemberSessionOk   = " + isUserSessionOk);
    play.Logger.info("isMemberLoginLogOk  = " + isMemberLoginLogOk);
    play.Logger.info("clientSessionId     = " + utilsSession.getClientSession(request()));
    play.Logger.info("clientsessionSign   = " + utilsSession.getClientSessionSign(request()));
    play.Logger.info("serverCache         = " + this.cache.get(userSession.getSessionId()));
  }


  // Step 2 : 取得登入請求
  private AuthRequest getAuthRequest() {
    try {
      AuthRequest authRequest = formFactory.form(AuthRequest.class).bindFromRequest().get();
      play.Logger.info("authRequest = "+ Json.toJson(authRequest));
      if(authRequest.getEmail() == null && authRequest.getPassword() == null){
        return null;
      }
      String role = authRequest.getRole();
      if(!UserRole.MEMBER.equals(role)){
        authRequest.setRole(UserRole.MEMBER.toString());
      }
      return authRequest;
    } catch (Exception e) {
      Logger.error("表單內容非登入資訊,轉換類別錯誤,回傳空物件");
    }
    return null;
  }


  // 查詢是否有該會員Session資料
  private UserSession getUserSession(String sessionId){
    try{
      UserSession userSession = this.webService.getUserSession(sessionId);
      if(userSession == null){
        return null;
      }
      return userSession;
    } catch (Exception e){
      e.printStackTrace();
      return null;
    }
  }


}


app/modules/AopModule.java
當我們完成所有Aop的相關程式後,就要寫好modulePlay能去把Aop常駐在Play上。特別注意bindInterceptor時,每個參數的意義,其中Matchers.any()代表意思是,只要任何的類別或方法都會被檢視到。如果剛好發現符合我們寫好的Matchers.annotatedWith(AuthCheck.class)的時候,就會執行相關的Interceptor(攔截器),而Interceptor的擺放位置,也會攸關整個程式的執行順序,我們就依照我們寫好的Interceptor依序擺放下去。

package modules;

import com.google.inject.AbstractModule;
import com.google.inject.matcher.Matchers;

import annotation.AuthCheck;
import aop.AfterBlocker;
import aop.AuthBlocker;
import aop.advice.BeforeAndAfterAdvice;
import aop.BeforeBlocker;

public class AopModule extends AbstractModule {

  @Override
  protected void configure() {
    BeforeAndAfterAdvice advice = new BeforeAndAfterAdvice();
    bindInterceptor(Matchers.any(), Matchers.annotatedWith(AuthCheck.class),new BeforeBlocker(advice),new AuthBlocker() , new AfterBlocker(advice));  
  }

}


conf/application.conf
最後我們需要在application.conf啟用我們寫好的AopModule即可。

...
play.modules.enabled += "modules.AopModule"
...



Step 4 : Setting Controller and routes

app/controller
WebController.java
新增登入檢查與登出。

@AuthCheck
public Result doLogin(){
  return ok("登入成功");
}

// 登出
public Result logout(){
  new Utils_Session().clearClientCookie(response());
  return redirect(controllers.routes.WebController.index().url());
}


routes
針對我們登入與登出,需要對應的執行網址。

# http://127.0.0.1:9000/web/login
GET        /web/login                  controllers.WebController.login()

# http://127.0.0.1:9000/web/login
POST    /web/login                  controllers.WebController.doLogin()

# http://127.0.0.1:9000/web/logout
POST    /web/logout                  controllers.WebController.logout()



Step 5 : Test Case
程式寫好一定要進行測試,接下來我們將針對我們實作好的Aop會員登入驗證程式,進行各種測試案例,驗證Aop是否正確攔截到有問題的案例。

[尚未登入前]
這部份測試,是使用者尚未登入網站前的測試,而進入AuthBlocker檢查,會是從註解的驗證Step2開始依序檢查下去。

CASE1 : 沒有輸入註冊信箱與密碼


CASE2 : 您尚未註冊成為會員!


CASE3 : 您的帳號尚未認證成功!


CASE4 : 您的帳號已被停權!


CASE5 : 密碼錯誤,請再次確認密碼是否正確


CASE6 : 成功登入,寫入Cookie
note : 實際上會寫入Session table, login log, server cache, bowser cookie


[登入之後檢查]
這部份的測試是使用者已經登入時,會根據使用者目前的Cookie跟我們的Server Cache或者是user_session進行身份驗證,而進入AuthBlocker檢查,會是從註解的驗證Step1開始依序檢查下去,若真的發現沒使用者資料時,會回到Step2步驟,進行重新登入動作。

CASE1 : cache有資料,但與使用者Cookie比對不符,沒通過檢查。


CASE2 : cache有資料比對通過,但超過24小時未更新Cookie,進行更新


CASE3 : 資料庫表單user_session沒有會員資料的登入資訊。


CASE4 :
資料庫user_session表單有資料,但與使用者Cookie比對不符,沒通過檢查。


CASE5 : user_session資料比對通過,但超過24小時未更新Cookie,進行Play CacheCookieuser_session進行更新或寫入的動作。


[Final]
以上測試確認無誤之後,我們所打造的Aop登入與驗證身分的AuthBlocker程式,就可以順利達成登入與檢查身分的功能了。當使用者進行登入前,跟登入後的驗證,都可以順利被攔截到,進行相關的檢查。之後如果有需要驗證身分的頁面時,只要增加@AuthCheck的檢查註記,就會進行身分檢查,對網站來說,會是很方便的設計。


[Reference]
1.Spring AOP APIs
http://docs.spring.io/spring/docs/current/spring-framework-reference/html/aop-api.html

2.Guice--IT大道
http://www.itdadao.com/e/search/result/?searchid=659

3.Java JCE - AES 的 Encryption & Decryption @2016-05-01
http://ijecorp.blogspot.tw/2016/05/java-jce-aes-encryption-decryption-2016.html

4.aop-on-plain-java-application
http://stackoverflow.com/questions/34056359/aop-on-plain-java-application

5.觀點導向程式設計(AOP)與代理機制實作
http://www.runpc.com.tw/content/content.aspx?id=109879

6.Proxy Pattern
http://twmht.github.io/blog/posts/design-pattern/proxy.html

7.Guice AOP
https://github.com/google/guice/wiki/AOP

8.how-to-generate-a-random-alpha-numeric-string
http://stackoverflow.com/questions/41107/how-to-generate-a-random-alpha-numeric-string

9.AOP 觀念與術語
http://openhome.cc/Gossip/SpringGossip/AOPConcept.html

10.Java Dynamic Proxy: What is a Proxy and How can We Use It
https://dzone.com/articles/java-dynamic-proxy

11.AES 對稱式加解密法
http://www.codedata.com.tw/social-coding/aes/

12.simple-java-aes-encrypt-decrypt-example
http://stackoverflow.com/questions/15554296/simple-java-aes-encrypt-decrypt-example/22445878#22445878

results matching ""

    No results matching ""