1. 程式人生 > >用Spring Boot & Cloud,Angular2快速搭建微服務web應用

用Spring Boot & Cloud,Angular2快速搭建微服務web應用

接下來我們來看看如何增加許可權控制,即提供使用者認證和鑑權的功能。首先有3個比較重要的架構設計選擇:

  1. 使用Spring的OAuth 2.0,還是使用Spring Session。雖然Spring對OAuth 2.0的支援已經很完善了,簡化了大量的配置和開發,但是OAuth 2.0本身還是比較複雜的,尤其是要使用JWT(JSON Web Tokens)和CORS的情況下。OAuth 2.0的應用場景,也包含了許多我們當前並不需要的功能。從簡單夠用的理念出發,我決定先選擇使用Spring Session。
  2. 使用者登陸和Session管理的功能放在哪裡?使用者登陸很自然的放在gateway裡面會比較好,作為整個網站的入口。不過裡面有一個問題是使用者的資訊放在資料庫裡面。如果登陸放在gateway專案裡面,會與user-service有一些重複的程式碼。考慮到登陸和user-service實際上是不同的功能,重複的程式碼也就是一個User類,還有一個findByUsername方法,還是決定將登陸放在gateway。
  3. portal是繼續放在node.js在用npm單獨執行,還是放在gateway的resource裡面,作為gateway執行時的一部分?如果放在gateway裡面,則前端的開發不能完全脫離後端,在開發中會喪失一部分的靈活性(不過前端也不可能完全脫離後端)。如果在node.js裡面執行,則後端需要考慮CORS的問題。瀏覽器在跨域訪問的時候,還會在實際的HTTP請求之前,先插入一個preflight的請求,請求方法是OPTIONS。為了支援CORS,後端需要做很多配置,包括安全配置(允許OPTIONS請求),CORS過濾器(或者配置器)等。CORS的配置需要允許跨域訪問,也帶來一下安全隱患。並且在正式部署中,如果用Angular2 Cli工具打包portal,則這些配置可能都沒有用處了。本著不過度設計的原則,我決定先選擇將前端放在gateway裡面。

那麼下面的第一步就是把portal目錄移到到gateway/main/java/resource/static目錄下面。我嘗試了在Windows下面使用Symlink,但是Eclipse當前版本(Neon)不支援Windows的Symlink,即不能正常解析裡面的檔案。如果用Eclipse自帶的Linked Folder,Maven又不能正常拷貝里面的檔案到target,所以只好老老實實的將portal的內容移動到static,並且刪除portal。 Spring Boot提供的使用者認證和鑑權功能,牽涉到了Spring Session,Spring Security的功能。

Spring Session

Spring Session提供了使用者session管理的實現和介面。基本上Spring預設的實現可以滿足大部分應用的需求。實現還提供了和HttpSession的整合,並且預設使用的是流行的Session管理記憶體No SQL資料庫系統Redis。對於使用RESTful API的微服務,Spring Session通過在HTTP Header中新增session ID的方式來支援。在使用Spring Boot時,所有這些都基本上不需要開發人員編寫程式碼,只需要在pom中增加依賴spring-boot-starter-redis和spring-session。具體請參考:http://projects.spring.io/spring-session/,以及http://docs.spring.io/spring-session/docs/current/reference/html5/

Spring Security

Spring Security實現了基於角色的訪問控制,並且支援CORS,CSRF等。具體請參考:http://projects.spring.io/spring-security/,以及http://docs.spring.io/spring-security/site/docs/current/guides/html5/。不過即使是基於spring-boot-starter-security,也需要開發人員根據專案情況進行配置,編寫配置程式碼。如果使用者的資訊在資料庫中,則需要實現UserDetailsService的loadUserByUsername方法,供Spring Security呼叫,以取得登陸使用者的密碼,狀態資訊和角色資訊。另外還要繼承WebSecurityConfigurerAdapter類,並且至少重寫引數為HttpSecurity的configure方法。 這裡值得一提的是,Spring Security從版本3開始,支援BCrypt演算法。該演算法除了比MD5和SHA1強度更高,更不容易被暴力破解以外,另一個特點就是產生的最終密碼包含了salt(鹽)。為了防止拖庫之後的彩虹表攻擊,如果採用SHA1,一般還需要在資料庫的使用者表中增加一個salt欄位,存放鹽值。這樣就比較麻煩,另外如果鹽值不是隨機的,或者生成的演算法不好,也比較容易受到攻擊。BCrypt解決了這個問題,該演算法產生的最終密碼包含了一個隨機的鹽值,驗證的時候無需再提供單獨的鹽值。具體請參考:https://en.wikipedia.org/wiki/Bcrypt。要使用該演算法,需要重寫引數為AuthenticationManagerBuilder的configure方法。

gateway實現

UserDetailServiceImpl.java

package com.healtrav.session;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    private final UserRepository userRepo;

    @Autowired
    public UserDetailServiceImpl(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        return this.userRepo.findByUsername(username)
                .map(user -> new User(
                        user.getUsername(),
                        user.getPassword(),
                        !user.getState().equals("expired"),
                        !user.getState().equals("locked"),
                        !user.getState().equals("credentialExpired"),
                        !user.getState().equals("disabled"),
                        AuthorityUtils.createAuthorityList(user.getRoles())))
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }

}

使用了Spring JPA的UserRepository,從MySQL資料庫的user表,根據使用者名稱讀取使用者。

UserRepository.java

package com.healtrav.session;

import java.util.Optional;

import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

public interface UserRepository extends Repository<User, Long> {

    Optional<User> findByUsername(@Param("username") String username);
}
與user-service裡面的UserRepository不同,gateway裡面實現是繼承了Repository這個基本實現類,原因是不需要那麼多方法。

WebSecurityConfigurer

package com.healtrav.session;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    UserDetailServiceImpl userService;

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.userDetailsService(userService)
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .httpBasic()
            .and()
            .logout()
            .and()
            .authorizeRequests()
                .antMatchers(
                        "/*",
                        "/login",
                        "/app/**",
                        "/node_modules/**")
                    .permitAll()
                .anyRequest()
                    .authenticated()
            .and().csrf().csrfTokenRepository(
                    CookieCsrfTokenRepository.withHttpOnlyFalse());
        // @formatter:on
    }

}
該類設定了UserDetaiService和BCrypt,以及一些基本的許可權。對於用Angula2來說,在根目錄,app和node_modules下面,都有一下靜態的檔案,包括html,css和js檔案,所以需要開放他們的許可權。另外對於Angular2來說,需要設定CSRF token儲存,否則瀏覽器沒有辦法取得正確的CSRF token,Spring Security會認為發生了CSRF攻擊。另外我們使用了HTTP Basic的密碼驗證方法。這個聽起來好像不是很安全,其實並沒有降低安全的級別,只是用起來更加方便,即Angular2的登陸頁面可以不用post,只需要用get,並且在HTTP Header裡面加入登陸的使用者名稱和密碼。當然正式的產品需要使用HTTP S協議來保證安全。實際的安全性跟用表單post的形式沒有區別。

LoginController.java

package com.healtrav.session;

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

@Controller
public class LoginController {

    @RequestMapping("/login")
    @CrossOrigin(origins = "*", maxAge = 3600)
    public String login() {
        return "forward:/";
    }

}
login成功會轉到static目錄下面。其實@CrossOrigin註解是不需要的,因為我們採用了將portal程式碼移入gateway的方法。

PrincipalController.java

package com.healtrav.session;

import java.security.Principal;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PrincipalController {

    @RequestMapping("/user")
    Principal principal(Principal principal) {
        return principal;
    }

}
這是Spring的一個小技巧,用來獲取當前使用者資訊,如果獲取到,則說明已經登陸。

SessionPreFilter

package com.healtrav.session;

import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;

public class SessionPreFilter extends ZuulFilter {

    @Autowired
    private SessionRepository<?> repository;

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpSession httpSession = ctx.getRequest().getSession();
        Session session = repository.getSession(httpSession.getId());
        ctx.addZuulRequestHeader("Cookie", "SESSION=" + session.getId());

        return null;
    }

}
通過Zuul Pre過濾器,將session的資訊傳遞給微服務。這個比較關鍵,否則user-service拿不到使用者的session,會認為是匿名使用者而拒絕訪問。另外在GatewayApplication上也需要加上註解@EnableRedisHttpSession(redisFlushMode = RedisFlushMode.IMMEDIATE),告訴Redis立即儲存session。

application.properties

zuul.routes.user-service.url=http://localhost:8081
ribbon.eureka.enabled=false
server.port=8080

logging.level.org.springframework.security=DEBUG
security.sessions=ALWAYS

# MySQL data source settings to user authentication
spring.datasource.url=jdbc:mysql://localhost:3306/healtrav
spring.datasource.username=root
spring.datasource.password=

spring.datasource.initial-size=20
spring.datasource.max-idle=60
spring.datasource.max-wait=10000
spring.datasource.min-idle=10
spring.datasource.max-active=200

gateway增加了secuiry.session=ALWAYS的配置表示總是建立session。

user-service實現

WebSecurityConfigurer.java

package com.healtrav;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http.httpBasic().disable()
                .authorizeRequests()
                    .antMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
                    .anyRequest()
                    .authenticated()
                .and()
                    .csrf().csrfTokenRepository(
                            CookieCsrfTokenRepository.withHttpOnlyFalse());
        // @formatter:on
    }

}

配置user-service所以的訪問請求都需要ADMIN這個role。

application.properties

# MySQL data source settings
spring.datasource.url=jdbc:mysql://localhost:3306/healtrav
spring.datasource.username=root
spring.datasource.password=

spring.datasource.initial-size=20
spring.datasource.max-idle=60
spring.datasource.max-wait=10000
spring.datasource.min-idle=10
spring.datasource.max-active=200

# auto create tables and data for database healtrav
spring.jpa.generate-ddl=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.datasource.schema=..\..\..\db\schema.sql
spring.datasource.data=..\..\..\db\data.sql

# show each sql for debug
spring.jpa.show-sql = true

spring.application.name=user-service
server.port=8081
server.address: 127.0.0.1

security.sessions: NEVER
logging.level.org.springframework.security: debug
設定security.sessions=NEVER,即user-service永遠也不會建立session,總是從redis那裡根據session ID讀取session。

Angular2實現

portal的頁面也有了一些變化,增加了一個登陸資訊的導航欄,登陸之後會顯示使用者名稱字(從gateway的PrincipalController獲取登陸使用者資訊)。並且登陸之後,可以從User Management連結獲取系統的使用者列表。

user.service.ts

import { Injectable } from '@angular/core';
import { Headers, Http, RequestOptions, Response } from '@angular/http';

import { User } from './user';
import 'rxjs/add/operator/toPromise';

@Injectable()
export class UserService {

    constructor (
        private http: Http
    ) { }

    private login_url = 'http://localhost:8080/login'
    private principal_url = 'http://localhost:8080/user'
    private users_url = 'http://localhost:8080/user-service/users'

    principal: User = null;

    getAllUsers(): Promise<User[]> {
        return this.http.get(this.users_url)
            .toPromise()
            .then(function(response: Response) {
                return response.json()._embedded['users'];;
            })
            .catch(this.handleError);
    }

    getPrincipal(): Promise<User> {
        return this.http.get(this.principal_url)
            .toPromise()
            .then(function(response: Response) {
                return response.json().principal;
            })
            .catch(this.handleError);
    }

    login(username: string, password: string): Promise<string> {
        //let headers = new Headers({ authorization: 'Basic ' + btoa(user.username + ':' + user.password) });
        let auth = 'Basic ' + btoa(username + ':' + password);
        let headers = new Headers();
        headers.append('Authorization', auth);
        return this.http.get(this.login_url, { headers: headers })
            .toPromise()
            .then(function(response: Response) {
                return 'success';
            })
            .catch(this.handleError);
    }

    private handleError (error: any) {
        let msg = (error.message) ? error.message :
            error.status ? `${error.status} - ${error.statusText}` : 'unknown error';
        console.error(msg); // log to console instead
        this.principal = null;
        return Promise.reject(msg);
    }
}
注意登陸是通過增加了一個HTTP Authorization Header。並且通過principal: User是否為空,來判斷是否登陸成功。

app.component.ts

import { Component } from '@angular/core';
import { Router } from '@angular/router';

import { User } from './shared/user';
import { UserService } from './shared/user.service';

@Component({
    selector: 'healtrav-app',
    templateUrl: 'app/app.component.html'
})
export class AppComponent {

    username: string = null;
    password: string = null;

    constructor(
        private router: Router,
        private userService: UserService,
    ) { }

    login() {
        this.userService.login(this.username, this.password).then(
            result => {
                console.log(result);
                this.userService.getPrincipal().then(
                    principal => {
                        this.userService.principal = principal;
                        console.log(this.userService.principal);
                    },
                    error => console.error(error)
                )
                this.router.navigate(['']);
            },
            error => console.error(error)
        );
    }
}
登陸後立刻呼叫userService的getPrincipal方法獲取登陸使用者資訊,來判斷是否登陸成功。

app.module.ts

import { NgModule, Injectable } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpModule, BrowserXhr } from '@angular/http';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { routing, appRoutingProviders } from './app.routing';

import { UserService } from './shared/user.service';
import { HomeModule } from './home/home.module';
import { UserManagementModule } from './user-management/user-management.module';

@Injectable()
export class CorsBrowserXhr extends BrowserXhr {
    constructor() {
        super();
    }

    build(): any {
        let xhr:XMLHttpRequest = super.build();
        xhr.withCredentials = true;
        return <any>(xhr);
    }
}

@NgModule({
    imports: [
        BrowserModule,
        HttpModule,
        FormsModule,
        routing,
        HomeModule,
        UserManagementModule
    ],
    declarations: [
        AppComponent
    ],
    providers: [
        { provide: BrowserXhr, useClass:CorsBrowserXhr },
        appRoutingProviders,
        UserService
    ],
    bootstrap: [ AppComponent ]
})
export class AppModule { }

CorsBrowserXhr類通過覆蓋預設的瀏覽器Xhr請求,全域性的設定了withCredentials為true,即告訴Angular2,每個XHR請求都帶上cookie資訊,放到請求的HTTP Header。這個比較關鍵。因為登陸時的CSRF和Session資訊,都是通過gateway的HTTP響應的Set-Cookie頭,存入瀏覽器的。如果沒有這個配置,瀏覽器不會將這兩個cookie,放置到XHR請求頭。不過這個類的名字起的不恰當,應該叫CredentialBrowserXhr之類的。

驗證

在使用瀏覽器驗證之前,我們可以先用curl工具看看伺服器是否配置成功。前半部分命令和響應如下:
$ curl -v -i http://localhost:8080/login -u cuiwader:1
* timeout on name lookup is not supported
*   Trying ::1...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to localhost (::1) port 8080 (#0)
* Server auth using Basic with user 'cuiwader'
> GET /login HTTP/1.1
> Host: localhost:8080
> Authorization: Basic Y3Vpd2FkZXI6MQ==
> User-Agent: curl/7.48.0
> Accept: */*
>
< HTTP/1.1 200
< Set-Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b;path=/
< X-Application-Context: application:8080
< Last-Modified: Sun, 16 Oct 2016 15:24:12 GMT
< Accept-Ranges: bytes
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Set-Cookie: SESSION=3353e5c5-ccc8-46b1-944b-4031a268c8a8;path=/;HttpOnly

可以看到curl傳送的請求包含了一個Authorization頭,內容是Base64編碼的使用者名稱:密碼。伺服器返回了Set-Cookie響應頭,設定XSRF-TOKEN和SESSION。Session的範圍是/,並且是HttpOnly。 之後可以用curl驗證是否登陸成功:
$ curl -v -i http://localhost:8080/user -H "Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b; SESSION=3353e5c5-ccc8-46b1-944b-4031a268c8a8"
*   Trying ::1...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*                                                                          
> GET /user HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.48.0
> Accept: */*
> Cookie: XSRF-TOKEN=54797b38-7fac-4942-8057-8989617f140b; SESSION=3353e5c5-ccc8                                                                        
>
< HTTP/1.1 200
< X-Application-Context: application:8080
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Sun, 16 Oct 2016 17:04:55 GMT
<
{ [362 bytes data]
100   355    0   355    0     0  11451      0 --:--:-- --:--:-- --:--:-- 11451HT                                                                         
X-Application-Context: application:8080
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 16 Oct 2016 17:04:55 GMT

{"details":{"remoteAddress":"0:0:0:0:0:0:0:1","sessionId":null},"authorities":[{"authority":"ADMIN, USER"}],"authenticated":true,"principal":{"password":
null,"username":"cuiwader","authorities":[{"authority":"ADMIN, USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,
"enabled":true},"credentials":null,"name":"cuiwader"}                                                                   
* Connection #0 to host localhost left intact

看到這些內容說明伺服器正常,可以用瀏覽器開啟頁面了。