Ch11-3 : Login
[Login]
前面兩個小節介紹到登入需要的資料庫表單,以及相關檔案架構,這個章節會詳細說明,我們登入功能是如何思考與規劃,以及最後實作的部份。
[Think]
如果只是單純設計登入功能,那會是很容易的事情,但是如果需要實作出公用的登入檢查程式,套用在之後需要檢查的頁面時,這部份要深入思考合適的設計方式,才會讓程式朝向更好的發展。
這次我採用的設計方法是Aspect-Oriented Programming,它的設計理念就像是從旁去檢視的裁判一般,從旁去觀察,程式執行的動作,是否符合我們訂製好的規範。若發現有問題,可以直接干涉程式的運行,進行必要的處理。也可以用來單純觀察和紀錄程式的運行過程。
Aop技術本源是個設計模式的Proxy(代理模式)的應用,而相關參考資料,可以看Reference說明與解釋。
既然我們決定好使用Aop的方式打造登入功能,我們就必須去選擇要使用那一套Java的Aop技術。而Java Aop比較常見的有aspectj與AOP Alliance。而Spring-Aop有分別針對這兩個Aop程式提供很好的支援。
因Play從2.3.8之後開始把GlobalSettings Deprecated掉,其中getControllerInstance整個拔除掉,不能用原本Spring get Bean來Instance我們的Controller,導致無法監聽我們的方法或類別,若要使用Aop技術,我們要改用新版Play所使用的Guice的Aop來實作這個功能,而Gucie含有AOP Alliance的實作,為了程式容易實作出Aop功能,我們是會採用AOP Alliance 與 Spring-Aop,來打造登入功能。
[Design]
在進行程式撰寫前,我們要先知道我們的Aop流程在Play上是怎麼運行的。我會從Play與Login分別來說明。
Play
Guice從Play 2.4.0開始導入。而啟動Play的方式不再推薦使用GlobalSettings,而使改用Guice modules方式,讓您自訂要啟動的服務。而Play啟動之後,就會您把您定義好的modules,常駐Play上,當有需要使用到時,就會用Inject方式,去使用儲存好的服務。而Aop當然也可以用這種方式,常駐在Play身上,等待需要使用時再被呼叫及使用。
Login
登入本身是在正常不過的動作,而我們網站設計,需要設計出不需要每次重新登入的功能,我們需要儲存一些資訊在使用者的瀏覽器上,藉由這些資訊,來跟我們的Play cache與user_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 , 新增相關的pojo與services。
- Step 3 : Aop , 新增相關Aop程式。
- Step 4 : Setting Controller and Routes , 新增對應的Controller與routes設定。
- 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
新增相關的pojo與services。
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
共用的Before與After的Advice,Spring-Aop特別針對AOP Alliance,新增更多的Advice功能,而Play可以完整支援到Spring的Aop部份。這個方法我用來簡單紀錄方法進來時與結束的部份,還有計算方法結束後耗時了多久。
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 Alliance的MethodInterceptor,新增了MethodBeforeAdviceInterceptor與AfterReturningAdviceInterceptor讓我們的程式,可以在執行前與執行後,進行更為詳細的控制。
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對稱式加解密法,我們隨機產生一組Key與IV,跟我們的Json字串,進行加解密動作,確保我們儲存的cookie與Session資料不容易被破解且使用。而相關的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的相關程式後,就要寫好module讓Play能去把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 Cache、Cookie、user_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