Google Code Prettify

2017年12月31日 星期日

第一個 spring boot web 程式 (使用 embedded tomcat)

經過了一年八個月,這個世界的技術已經又有改變 ... (其實是我當時還沒學到比較新的技術)
現在要用 spring boot 來改寫 spring MVC - getting started 這一篇的程式,同樣的,架構採用 spring MVC,spring framework 最讓人詬病的就是設定非常複雜,spring boot 的出現主要就在解決這個問題。現在開始來看一下程式怎麼寫 …


上圖是程式碼放罝的位置,這是 Gradle 的習慣。
  1. embedded tomcat 不支援 jsp,所以這個程式的顯示都使用 thymeleaf 板型的 html,這是 spring boot 提供的,支援 html 5。
  2. 網頁相關的檔案放在 /src/main/resources 目錄下,html 放在 templates 子目錄,css、javascript 放在 static 子目錄。
  3. spring boot 的設定預設放在 application.properties,這個檔通常放在 classpath 的根目錄 【註】。
buildscript {
    ext {
        springBootVersion = '1.5.9.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

group = 'idv.steven'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa') 
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-thymeleaf')
    compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.2.1'
    compile group: 'javax.inject', name: 'javax.inject', version: '1'
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
這是 gradle 的設定檔,前一篇採用 maven,這裡使用 gradle,兩個都很流行,看個人習慣,沒什麼特別的好壞之分。紅色那一行,設定了我們要採用的 spring boot 版本,藍色那行指定了要使用 spring boot 插件,上面那行深綠色,指出要依賴 spring boot 插件。因為現在要寫的程式是 web 程式,且會用 JPA 到資料庫中存取使用者資料,並且網頁會套用 thymeleaf 版型,所以寫了橘色那三行設定,gradle 會自行去找出相關依賴的 jar 檔。紫色那行是引入 MariaDB 的 jar 檔,因為這裡的資料庫是使用 MariaDB
package idv.steven.demo.database.entity;

import java.sql.Timestamp;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Data;

@Entity
@Table(name="users")
@Data
public class UsersEntity {
    @Id
    private String name;
    private String password;
    private Timestamp createtime;
}
上面是對應到資料庫 table Users 的 entity。
package idv.steven.demo.database.dao;

import org.springframework.data.repository.CrudRepository;
import idv.steven.demo.database.entity.UsersEntity;

public interface UsersDao extends CrudRepository {

}
存取 table Users 就採用 spring data 提供的 CrudRepository,不知道怎麼用可以參考「spring data: CrudRepository 自動生成」。
server.port=8080

spring.datasource.url=jdbc:mariadb://192.168.0.103:3306/demo
spring.datasource.username=root
spring.datasource.password=p@ssw0rd##
  • 資料庫連線相關設定,只要在 application.properties 中如上設定即可,通常只要設定 url、username、password,不需要設定 driver-class-name,spring boot 從 url 就能判斷現在要使用的是那一種資料庫。
  • 第一行 server.port 是設定 tomcat 要啟動後監聽那一個 port,預設就是 8080,如果本來就是要使用 8080,這一行可以刪除。與 tomcat 相關的設定還有:
    • server.session-timeout (以秒為單位)
    • server.context-path (預設為 / )
    • server.tomcat.uri-encoding (預設為 UTF-8)
    • server.tomcat.compression (預設為 off)
package idv.steven.demo.controller;

import javax.inject.Inject;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import idv.steven.demo.database.dao.UsersDao;
import idv.steven.demo.database.entity.UsersEntity;
import idv.steven.demo.model.IndexForm;

@Controller
public class IndexController {
    @Inject
    private UsersDao daoUsers;
 
    @RequestMapping("/index")
    public String login(IndexForm form) {
        if (form.getUserName() != null) {
            UsersEntity user = daoUsers.findOne(form.getUserName());
   
            if (user != null && user.getPassword().equals(form.getPassword())) {
                return "success";
            }
        }
        else {
            return "login";
        }
  
        return "fail";
    } 
}
Controller 如上所示,很簡單的針對傳入的登入帳號、密碼,檢查與資料庫中的是否相同? 相同就導向 success.html 網頁,不相同就導向 fail.html 網頁。
<html xmlns:th="http://www.thymeleaf.org">
<head>
 <link rel="sytlesheet" th:href="@{bootstrap/css/bootstrap.min.css}"></link>
 <link rel="sytlesheet" th:href="@{bootstrap/css/bootstrap-theme.min.css}"></link>
</head>
<body>

 <form action="index.do" method="post">
        帳號: <input name="userName" type="text" />
        密碼: <input name="password" type="password" />
        <input type="submit" value="登入" />
    </form>
</body>
</html>
登入頁,如上所示,簡單的輸入帳號、密碼後按【登入】。
package idv.steven.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApp {
 
    public static void main(String[] args) {
        SpringApplication.run(DemoApp.class, args);
    }
}
最後很重要的,spring boot 要有一個起始的類別,這個類別上面要寫上 @SpringBootApplication 註記,這樣 spring boot 會自動的掃描所有類別與 jar 檔。


如果是在 eclipse 中開發程式,可以在專案名稱上按右鍵,選用「Run As > Spring Boot App」,spring boot 就會啟動 embedded tomcat,然後可以打開瀏覽器測試。
如上,輸入帳號、密碼後按【登入】,即會導向 Controller,再由 Controller 判斷要導向 success.html 或 fail.html。

【註】
application.properties 可以放在以下四個位置:
  1. 外置,在相對於應用程式運行目錄的 /config 子目錄裡。
  2. 外置,在應用程式運行的目錄裡。
  3. 內置,在 config package 內。
  4. 內置,在 classpath 根目錄。
spring boot 會依上列順序搜尋。
〈參考資料〉https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html#boot-features-external-config-application-property-files

【補充】
上面的程式是 compile 成 jar 檔,執行在 spring boot 包裝進 jar 檔裡的 embedded tomcat 上,如果想要 compile 成 war 檔並執行在一般的 tomcat,要如下修改:
  • DemoApp.java (啟動類別)
package idv.steven.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.support.SpringBootServletInitializer;

@SpringBootApplication
public class DemoApp extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(DemoApp.class);
    }
 
    public static void main(String[] args) {
        SpringApplication.run(DemoApp.class, args);
    }
}
如紅色部份所示,繼承 SpringBootServletInitializer,並覆寫 configure method。
  • build.gradle
apply plugin: 'war'
在 build.gradle 中加上如上內容,導入 war 插件,並確定環境變數有設定 JAVA_HOME 及安裝了 gradle,然後在 build.gradle 所在路徑上下如下指令:
gradle war

2017年12月22日 星期五

在 linux 中無法切換身份? (su: Permission denied)

Linux 預設,只有 root 可以切換到別的身份 (帳號),如果要取消這個限制,怎麼做? 如下:

1. 變更授權規則
    編輯 /etc/pam.d/su 檔,找到 auth required 這個項目,原來的值可能如下:
auth            required        pam_wheel.so trust group=wheel
    將它改成如下:
auth            required        pam_wheel.so use_uid
2. 將要能切換到別的身份的帳號加入 wheel 群組
    假設想要 user1 和 user2 可以相互切換,那麼執行如下指令:
usermod -a -G wheel user1
usermod -a -G wheel user2 
再測試看看,應該沒問題了。

2017年9月4日 星期一

Installing and Configuring Docker (Scientific Linux 7.3)

為什麼 Docker 官網所列出來有支援的作業系統沒有 Scientific Linux ? 但實際上是安裝的起來的,只是方法似乎比較不一樣,在 Adaptive Computing 這家公司的網站反而有提供方法,如下:
  1. Make sure existing yum packages are up-to-date.
  2. yum update
  3. Install the Docker package.
  4. yum install docker
  5. Edit /etc/sysconfig/docker by replacing the existing OPTIONS line with the following.
  6. OPTIONS='-s devicemapper --storage-opt dm.fs=xfs --exec-opt native.cgroupdriver=cgroupfs'
  7. Start/restart Docker.
    systemctl restart docker.service
  8. Set Docker to start after reboot.
    systemctl enable docker.service
  9. Verify the installation.
    docker run dockerinaction/hello-world
【補充】
移除 docker:
    yum remove docker




【Linux 指令】
安裝軟體前常常需要先確認一下自己的 Linux 的版本,這裡提供兩個常用的指令 ...
  • lsb_release -a : 查看 Linux 的發行版本資訊
[steven@localhost ~]$ lsb_release -a
LSB Version:    :core-4.1-amd64:core-4.1-noarch:cxx-4.1-amd64:cxx-4.1-noarch:desktop-4.1-amd64:desktop-4.1-noarch:languages-4.1-amd64:languages-4.1-noarch:printing-4.1-amd64:printing-4.1-noarch
Distributor ID: Scientific
Description:    Scientific Linux release 7.3 (Nitrogen)
Release:        7.3
Codename:       Nitrogen
  • uname -a: 查看 Linux core 的版本
[steven@localhost ~]$ uname -a 
Linux localhost.localdomain 3.10.0-693.1.1.el7.x86_64 #1 SMP Tue Aug 15 08:36:44 CDT 2017 x86_64 x86_64 x86_64 GNU/Linux

2017年8月10日 星期四

Gradle: 編譯、測試 web 專案

Java 專案的編譯、測試、部署,從十幾年前很流行的 ant,到現在則是以 Maven 及 Gradle 最流行,這篇先簡單的介紹怎麼用 gradle 將 web 專案打包成 war 檔。
  • 安裝 Gradle
請到 Gradle 的官網下載 Gradle,並依上面步驟安裝設定好環境。
  • build.gradle


如果是用 eclipse 開發,在專案的根目錄下新增一個文字檔,檔名為 build.gradle,這個檔名不是強迫非取這個名稱不可,只不過這是預設的檔名,這一篇希望快速的用 gradle 來編譯、測試及包裝 war 檔,一切都採預設值。接下來還有一些目錄也都是 gradle 預設的,以後有空會說明怎麼使用非預設的目錄,但是這邊我們把程式都放到預設目錄,現在說明這些目錄的用處 (目錄當然要自己建立啦~)。
  1. src/main: 網站相關的檔案,像是 java、jsp、html、*.properties、*.xml ... 都放在這個目錄下的子目錄。
  2. src/main/java: 顧名思義,這裡是放 java 程式碼的地方。
  3. src/main/webapp: 這個目錄就相當於一般我們用 eclipse 產生一個 web project 時,eclipse 自動幫我們產生的 WebContent,這個目錄在部署後就會相當於網站的根目錄。
  4. src/test/java: 測試程式放這個目錄下,只要裡面有測試程式,每次執行 gradle 時,在 compile 並包裝成 war 這些工作完成後,gradle 還會來執行這些測試程式產生測試報告。
接下來直接看一個簡單的 build.gradle 吧 ~
 1 apply plugin: 'java'
 2 apply plugin: 'war'
 3 
 4 sourceCompatibility = 1.8
 5 version = '1.0'
 6 
 7 sourceSets {
 8     main {
 9         java {
10             srcDirs = ['src/main/java']
11         }
12         resources {
13             srcDirs = ['config']
14         }
15     }
16     test {
17         java {
18             srcDirs = ['src/test/java']
19         }
20     }
21 }
22 
23 buildDir = 'out'
24 
25 repositories {     
26      maven { url "http://maven.springframework.org/milestone" }
27      maven { url "http://repo.maven.apache.org/maven2" }
28      maven { url "http://repo1.maven.org/maven2/" }
29      maven { url "http://amateras.sourceforge.jp/mvn/" }
30      mavenCentral()
31 }
32 
33 configurations.all {
34     exclude group: 'org.freemarker', module: 'freemarker'
35     exclude group: 'javax.el', module: 'el-api'
36     exclude group: 'xerces', module: 'xercesImpl'
37     exclude group: 'xml-apis', module: 'xml-apis'
38     exclude group: 'jboss', module: 'javassist'
39     exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-core'
40     exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-el'
41     exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
42     exclude group: 'org.apache.tomcat', module: 'tomcat-jdbc'
43     exclude group: 'org.apache.tomcat', module: 'tomcat-juli'
44     exclude group: 'org.apache.ant', module: 'ant'
45     
46     resolutionStrategy {
47         force group: 'org.apache.ant', name: 'ant', version: '1.10.1'
48     }
49 }
50 
51 dependencies {
52     def tomcatVersion = '9.0.0.M22'
53     def springVersion = '4.3.10.RELEASE'
54     def springBootVersion = '1.5.4.RELEASE'
55     def hibernateVersion = '5.2.10.Final'
56     def hibernateValidatorVersion = '6.0.1.Final'
57     
58     compile fileTree(dir: 'library', include: ['*.jar'])
59     
60     testCompile group: 'junit', name: 'junit', version: '4.+'
61     
62     //javax
63     compile group: 'javax.inject', name: 'javax.inject', version: '1'
64     compile group: 'javax.servlet', name: 'javax.servlet-api', version: '3.1.0'
65     compile group: 'javax.activation', name: 'activation', version: '1.1.1'
66     
67     //spring framework
68     compile group: 'org.springframework', name: 'spring-context', version: "$springVersion"
69     compile group: 'org.springframework', name: 'spring-test', version: "$springVersion"
70 
71     //spring boot
72     compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: "${springBootVersion}"
73     
74     //Others
75     compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.7.0'
76 }
簡要說明如下:
  1. apply plugin: 引入插件,這裡引入 java 及 war 兩個插件,這樣就可以編譯、測試並包裝 war 檔了 (一般 console、desktop 程式不需要引入 war 這個插件)。
  2. sourceCompatibility: 指出編譯要用的 JDK 版本,這裡指出是要用 1.8 版。
  3. version: 設定我們自己的這個專案的版本編號
  4. sourceSets: 裡面比較值的注意的是 resources,設定檔我們是放在專案的根目錄下的 config 目錄裡,所以 12~14 是必要的,不然 gradle 不會知道要把這個目錄下的檔案也包裝進 war。至於 main/java、test/java 這兩個設定因為值都是 gradle 的預設值,在這裡是可省略的。
  5. buildDir: 指出編譯後的產出要放到那個目錄下。
  6. repositories: 指出 gradle 要到那些網站抓 jar 檔。
  7. configurations.all: gradle 抓 jar 檔的時候,會把相依的 jar 檔一併抓下來,不然,我們要找到所有相依 jar 檔會浪費非常大量年輕寶貴的生命,但是,有時候我們不想要其中的某些 jar 檔時,可以在這裡以 exclude group 將它排除,或是 gradle 抓下來的 jar 版本不是我們想要的,可以用 force group 強制限定版本。
  8. dependencies: 這是一般人最熟悉的部份,把我們專案需要的 jar 檔寫在這裡,有些 framework 會有非常多的 jar 檔,版號又一樣,那麼,可以先用 def 定義版號的變數,省去每次換版本的麻煩。
  9. compile fileTree: 有一些 jar 檔不是到網路上的 jar repository 裡抓的,是放在自己的電腦某處,像這裡,這類的 jar 檔是放在專案根目錄的 library 目錄下,所以要這個指令指出來。
  10. testCompile group: 指出測試時要用的 framework,這裡使用的是 JUnit 4.+ 版。
程式寫好,build.gradle 也準備好了,就在專案的目錄下打入 gradle build 指令,build 是 java 插件提供的一個任務 (task),這個任務會以正確的順序編譯、測試和打包。最後結果產生後應該會在 buildDir 指定的目錄下,看到如下的結果:
  • classes: java compile 後產生的 class 檔,都放在這個目錄下。
  • libs: war 檔放在這裡。
  • resources: 上面 build.gradle 中指定的資源檔會放在這個目錄下。
  • reports、test-results: 這裡放的是測試報告,reports 放的是每個測試的結果,test-results 則有每個測試詳細資訊,有錯誤時可以來這看看是什麼錯誤。


2017年8月6日 星期日

JWT 認證在 RESTful service 上的應用 (3) - Angular

在往下看之前,最好先復習一下「JWT 認證在 RESTful service 上的應用 (1) - JWT 說明」,確定 JWT 協定的規範,接下來我們會在 Angular 中製成 JWT 的 token 傳給 service 端。

這個程式會以如下的流程執行,作為 Client 端的 Angular 以帳密登入,經 service 端驗證通過後,會接收到一個 JWT 的 key,之後要 call service,都要用這個 key 產生 token 放入 header 以供認證。

  • 環境說明
請先在 Angular 專案中安裝下面兩個 package。
  1. angular2-jwt: 用來傳送 jwt token (npm install angular2-jwt)
  2. crypto-js: 用來加密及處理 Base64 字串 (npm install crypto-js)
安裝好之後,於 app.module.ts 中加入下面紅色部份的程式,這是 angular2-jwt 初始化所需要的。注意看一下第 4 行,這表示在 angular 送出 request 給 service 前,會從 sessionStorage 中的 'token' 取得 JWT token 傳給 service,有些人可能會存在 localStorage,這沒有強制規定,可自行修改,放在 sessionStorage 中也不一定要取名 'token'。
 1 export function authHttpServiceFactory(http: Http, options: RequestOptions) {
 2   return new AuthHttp(new AuthConfig({
 3         tokenName: 'token',
 4         tokenGetter: (() => sessionStorage.getItem('token')),
 5         globalHeaders: [{'Content-Type':'application/json'}],
 6     }), http, options);
 7 }
 8 
 9 @NgModule({
10   declarations: [
11     AppComponent,
12     LoginComponent,
13     HelloComponent
14   ],
15   imports: [
16     BrowserModule,
17     FormsModule,
18     HttpModule,
19     RouterModule.forRoot(appRoutes)
20   ],
21   providers: [
22       AuthenticationService,
23       HelloService,
24       {
25         provide: AuthHttp,
26         useFactory: authHttpServiceFactory,
27         deps: [Http, RequestOptions]
28       }
29   ],
30   bootstrap: [AppComponent]
31 })
32 export class AppModule { }
  • service 端程式修改
上一篇的 RESTful service 程式只寫了一個 say hello 的 controller,為了完成上面循序圖的流程,這裡增加一個 AuthenticationController 並提一個 login method,程式如下:
 1 @RestController
 2 @RequestMapping("/auth")
 3 public class AuthenticationController {
 4     @Value("${jwt.key}")
 5     protected String jwtKey;
 6     
 7     @RequestMapping(value = {"/login/{username}/{password}"}, method = RequestMethod.GET)
 8     public ResponseEntity<?> login(@PathVariable("username") String username, @PathVariable("password") String password) {
 9         
10         //檢查使用的帳密是否正確
11         
12         return new ResponseEntity<ResponseModel>(new ResponseModel(jwtKey), HttpStatus.OK);
13     }
14 }
檢查帳密的過程這裡省略了,直接回傳 JWT 加密的 key,上面程式傳回值時,是傳回一個 ResponseModel,程式碼如下,以 ResponseEntity 的方式傳回,spring 會將它處理成 JSON 格式。
 1 @Data
 2 public class ResponseModel implements Serializable {
 3     private String msg;
 4     
 5     public ResponseModel() { }
 6     
 7     public ResponseModel(String msg) {
 8         this.msg = msg;
 9     }
10 }
  • angular
在 angular 端,與 RESTful service 溝通的 client 程式,我習慣寫成 service,讓 component 呼叫,這裡先看一下呼叫 AuthenticationController 中 login method 的 client 怎麼寫。
 1 const SERVICE_URL = 'http://localhost:8080/demo/auth/';
 2 
 3 @Injectable()
 4 export class AuthenticationService {
 5     constructor(private http: Http) {
 6     }
 7 
 8     login(username: string, password: string): Observable<any[]> {
 9         
10         return this.http.get(SERVICE_URL + 'login/' + username + '/' + password)
11             .map(resp => resp.json())
12             .catch(this.handleError);
13     }
14 
15     private handleError(error: any) {
16         
17         return Observable.throw('login fail');
18     }
19 }
紅色部份即是傳給 service 端帳密,service 的回應會傳到 resp。登入的畫面如下,輸入帳密,按【登入】。

程式碼如下:
 1 @Component({
 2   selector: 'app-login',
 3   templateUrl: './login.component.html',
 4   styleUrls: ['./login.component.css']
 5 })
 6 export class LoginComponent implements OnInit {
 7     edited: boolean = true;
 8     username: string;
 9     password: string;
10     message: string;
11 
12     constructor(private router: Router, private authService: AuthenticationService) { }
13 
14     ngOnInit() {
15     }
16 
17     onLogin(event) {
18         this.authService.login(this.username, this.password)
19             .subscribe(
20                 data => {
22                     let respModel:ResponseModel = new ResponseModel(data);
24                     sessionStorage.setItem('jwtKey', respModel.msg);
26                     this.edited = false;
27                     this.router.navigate(['/hello']);
28                 }
29                 ,
30                 error => {
31                     console.log(error.message);
32                     this.message = '帳密錯誤';
33                 }
34             );
35     }
36 }
紅色部份即是當使用者按下登入觸發 onLogin 事件,呼叫 authService 的 login 後傳回值,在取得裡面的 JWT key 後將它放入 sessionStorage,畫面則導向 /hello 下一頁。下一頁的畫面如下:
很簡單的輸入名字後按【say Hello】,就會傳回值,例如輸入 Steven,就會傳回 Hello Steven,程式碼如下:
 1 @Component({
 2   selector: 'app-hello',
 3   templateUrl: './hello.component.html',
 4   styleUrls: ['./hello.component.css']
 5 })
 6 export class HelloComponent implements OnInit {
 7     edited: boolean = false;
 8     name: string;
 9     message: string;
10 
11     constructor(private router: Router, private helloService: HelloService) { }
12 
13     ngOnInit() {
14         let jwtKey:any = sessionStorage.getItem('jwtKey');
15         if (jwtKey == undefined || jwtKey == null) {
16             this.edited = false;
17             this.router.navigate(['/']);
18         }
19         else {
20             this.edited = true;
21         }
22     }
23 
24     onSay() {
25         this.helloService.say(this.name)
26             .subscribe(
27                 data => {
29                     let respModel:ResponseModel = new ResponseModel(data);
30                     this.message = respModel.msg;
31                     this.edited = false;
32                 }
33                 ,
34                 error => {
35                     this.message = 'call service fail';
36                 }
37             );
38     }
39 }
紅色部份是呼叫 helloService 的 say 後,傳回打招呼的字串,然後顯示出來在畫面。現在來看最重要的 helloService 怎麼寫,這裡就會根據第一篇講到的 JWT 規範,實作出 JWT token。
 1 import * as CryptoJS from 'crypto-js';
 2 
 3 const SERVICE_URL = 'http://localhost:8080/demo/say/';
 4 
 5 @Injectable()
 6 export class HelloService {
 7 
 8     constructor(private authHttp: AuthHttp) {
 9     }
10 
11     say(name: string): Observable<any[]> {
12         this.genToken();
13 
14         return this.authHttp.get(SERVICE_URL + 'hello/' + name)
15             .map(resp => resp.json());
16     }
17 
18     private genToken() {
19         let now:number = new Date().getTime();
20         
21         let jwtKey: string = sessionStorage.getItem('jwtKey');
22         
23         let header:string = '{\"alg\":\"HS512\",\"typ\":\"JWT\"}';
24         let payload:string = '{\"iat\":' + now + '}';
25         
26         let header64:string = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(header));
27         let payload64:string = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(payload));
28         
29         let encodedString:string = header64 + '.' + payload64;
30         let mac_data:any = CryptoJS.HmacSHA512(encodedString, CryptoJS.enc.Base64.parse(jwtKey));
31         let signature: string = CryptoJS.enc.Base64.stringify(mac_data);
32         
33         let token: string = header64 + '.' + payload64 + '.' + signature;
34         
35         sessionStorage.setItem('token', token);
36     }
37 
38 }
第 14、15 行就是呼叫 service 端的程式,重點在第 12 行,當這個 service 是需要 JWT 認證,在呼叫前要先產生 token 放入 sessionStorage,產生的方法在 genToken() method,說明如下:
  1. 在第一篇中,我們知道,這個認證的方式是發送端發送含 header、payload 及 signature 的字串給接收端,接收端可以透過 signature 的簽章檢查 header、payload 是否有被竄改,借此達到認證的目的。第 23、24 行先定義 header、payload 的值,service 端因為只要求要有發送時間,所以這裡在 iat 欄位放入發送時間,至於 alg 的值 HS512,這是一定要有的,表示是用 HS512 (HmacSHA-512) 這個演算法簽章的。
  2. 接下來要產生的 token 需要進行 Base64 轉換及 HmacSHA-512 的簽章加密,在 JavaScript 中有一個很有名的函式庫 CryptoJS,在第 1 行引入。
  3. 第 26、27 行使用 CryptoJS 提供的 CryptoJS.enc.Base64.stringify 函式將 header、payload 轉換成 Base64 格式,因為傳入的參數要是二進位值,先使用 CryptJS.eng.Utf8.parse 函式轉換。
  4. 第 30 行對 header、payload 進行簽章,原本我們存在 sessionStorage 的 JWT key 是 Base64 格式,這裡要轉換為 binary,所以使用 CryptoJS.enc.Base64.parse 函式轉換,經 CryptoJS.HmacSHA512 函式加密後,得到的簽章值存入 mac_data 變數。
  5. 第 31 行,將剛才得到的簽章轉換為 Base64 格式。
  6. 第 33 行,依規範將 header、payload、signature 連接起來,以 . 隔開,成為 token,在 35 行存入 sessionStorage 的 'token' 中,這個 token 前面不需要加 'Bearer ',因為 angular2-jwt 會幫忙加上。
  7. 在第 14 行,這裡呼叫 RESTful service 使用的不是 HttpModule 的方法,而是使用 angular2-jwt 提供的 AuthHttp,它會在送出 request 時,於 http header 中加入 token,於是就可以在 RESTful service 端進行認證了!




2017年8月3日 星期四

JWT 認證在 RESTful service 上的應用 (2) - RESTful service

上一篇 (JWT 認證在 RESTful service 上的應用 (1) - JWT 說明) 簡要說明了 JWT,這裡要用 spring 寫個 RESTful service,並用 JUnit 進行單元測試,這個程式很簡單,只是個 say hello 的 service,client 傳給 service 名字,例如 "Steven",那麼 service 會回覆 "Hello Steven"。先來看一下 RESTful 程式:
 1 @RestController
 2 @RequestMapping("/say")
 3 public class HelloController {
 4 
 5     @JwtValidation
 6     @RequestMapping(value = {"/hello/{name}"}, method = RequestMethod.GET)
 7     public ResponseEntity<?> hello(@PathVariable("name") String name) {
 8         
 9         return new ResponseEntity<HelloModel>(new HelloModel(name), HttpStatus.OK);
10     }
11 }
就如上所說,client 傳來了 name,service 端傳回一個 HelloModel 的 object,HelloModel 如下所示,只是在名稱前加上 Hello,因為是正常回覆,狀態設定為 OK (200)。特別注意第 5 行,這個 annotation 是我自己定義的,等一下我會用 spring AOP 的方式,針對有加 @JwtValidation 的 method 檢查 JWT token。
 1 @Data
 2 public class HelloModel implements Serializable {
 3     private String sentence;
 4     
 5     public HelloModel() { }
 6     
 7     public HelloModel(String name) {
 8         this.sentence = "Hello " + name;
 9     }
10 }
@JwtValidation 的程式碼如下:
1 @Retention(RetentionPolicy.RUNTIME)
2 @Target(ElementType.METHOD)
3 public @interface JwtValidation {
4 }
第 2 行指出,這個 annotation 只能用在 method。
spring AOP 不是這裡說明的重點,所以,直接看 @AspectJ 裡的程式怎麼檢查 JWT token。
 1 @Aspect
 2 @Log4j
 3 public class JwtValidator {
 4     @Value("${jwt.key}")
 5     protected String key;
 6     
 7     @Inject
 8     protected HttpServletRequest httpRequest;
 9     
10     //@Around("execution(* idv.steven.demo.rest.controller.*.*(..))")
11     @Around("@annotation(idv.steven.demo.JwtValidation)")
12     public ResponseEntity<?> validate(ProceedingJoinPoint jp) {
13         String token = httpRequest.getHeader("Authorization");
14         
15         if (!StringUtils.isEmpty(token)) {
16             try {
17                 token = token.replace("Bearer ", "");
18                 
19                 //當 client 與 server 的 key 不同,會產生 exception。
20                 Jws<Claims> jws = Jwts.parser()         
21                            .setSigningKey(DatatypeConverter.parseBase64Binary(key))
22                            .parseClaimsJws(token);
23                 
24                 Claims body = jws.getBody(); //payload
25                 Date issuedAt = body.getIssuedAt();
26                 
27                 if (issuedAt == null) {
28                     return new ResponseEntity<ResponseModel>(new ResponseModel(), HttpStatus.REQUEST_TIMEOUT);
29                 }
30                 else {
31                     //由 client 發出 request 到 server 端,最多可以歷經 30 秒! (各機器不一定有校時,要有一點容許誤差!)
32                     int diffSec = DateUtil.diffSec(issuedAt);
33                     if (diffSec > 30) {
34                         return new ResponseEntity<ResponseModel>(new ResponseModel(), HttpStatus.REQUEST_TIMEOUT);
35                     }
36                 }
37             }
38             catch (Exception ex) {
39                 log.error(ex.getMessage(), ex);
40                 return new ResponseEntity<ResponseModel>(new ResponseModel(), HttpStatus.UNAUTHORIZED);
41             }
42         }
43         else {
44             log.error("JWT token not found");
45             return new ResponseEntity<ResponseModel>(new ResponseModel(), HttpStatus.UNAUTHORIZED);
46         }
47         
48         try {
49             return (ResponseEntity<?>) jp.proceed();
50         } catch (Throwable e) {
51             return new ResponseEntity<ResponseModel>(new ResponseModel(), HttpStatus.SERVICE_UNAVAILABLE);
52         }
53     }
54 }
第 11 行指出,當 method 前加了 @JwtValidation,該 method 就會被這個 validate "包圍" (Around),於檢查完確定沒問題了,才執行第 49 行,第 49 行就是去執行被包圍的那個 method,在這裡指的就是最前面那個 hello method。在這裡可以發現,只要有任何錯誤,都會直接回傳帶有狀態碼非 HttpStatus.OK (200) 的其它值,這是 RESTful service 的習慣,充份利用 http 的特性,在 http 的狀態碼中挑一個接近於我們所要表達的錯誤,將它傳回給 client,完全不需要再自行定義任何狀態碼。

第 4、5 行,這是 spring framework 提供的功能,spring 會從設定檔 (application.properties 或其它 properties 檔) 載入 jwt.key 的值到 key 這個變數。當然,我在某個標有 @Configuration 的類別前會有 @PropertySource("classpath:application.properties") 這樣的標注。

第 13 行,從 http 的 header 中的 Authorization 取出 JWT token,還記得上一篇有提到,在 token 前會有 "Bearer " 的字串,在第 17 行將它移除,第 20 ~ 22 行是用 key 驗證 token 中的 header、payload 是否被竄改,如果沒有,將所有欄位的值放到 jws 那個變數裡,方便程式人員處理,這裡使用的是 JJWT 這個 open source,算是目前比較流行的。在 24 ~ 46 行就是在檢查 token 裡的一些值,不正確回覆非 HttpStatus.OK (200) 的值,這裡其實只檢查有沒有逾時。

接下來看一下測試程式怎麼寫 …
 1     @Test
 2     public void hello() {
 3         String url = "http://localhost:8080/demo/say/hello/Steven";
 4         
 5         Date iat = Calendar.getInstance().getTime();
 6         
 7         String compactJws = Jwts.builder()
 8                   .setIssuedAt(iat)
 9                   .signWith(SignatureAlgorithm.HS512, DatatypeConverter.parseBase64Binary(key))
10                   .compact();
11         
12         HttpHeaders headers = new HttpHeaders();
13         headers.set("Authorization", "Bearer " + compactJws);
14         
15         JSONObject parm = new JSONObject();
16         HttpEntity<JSONObject> entity = new HttpEntity<JSONObject>(parm, headers);
17         
18         try {
19             ResponseEntity<?> response = rest.exchange(url, HttpMethod.GET, entity, HelloModel.class);
20             HelloModel body = (HelloModel) response.getBody();
21             System.out.println(body.getSentence());
22             System.out.println("status code = " + response.getStatusCode());
23         }
24         catch (HttpClientErrorException ex) {
25             System.out.println("status code = " + ex.getStatusCode());
26         }
27     }
第 3 行,在網址後帶個名字當參數,這裡帶入 Steven,如果正確的話,應該會在第 21 行輸出 Hello Steven。第 7 ~ 10 行,用 JJWT 產生 JWT token,這裡的 key 因為我是以 Base64 儲存,所以要先轉成 binary,簽章演算法指定為 HS512 (SHA-512),產生後於 12 ~ 13 行放入 http header 的 Authorization 欄位,依慣例在 token 前加上 "Bearer "。第 19 行的 rest 是 spring 提供的 RestTemplate,用它來呼叫 RESTful service,如果傳回的狀態值為 OK (200),會執行 20 ~ 22 行,否則會產生 exception,執行 25 行印出帶回的 Http Status Code,這樣 client 就可以知道錯誤原因。