Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

主要內容:Spring Boot 2的基礎應用、CORS配置、Actuator監控;Spring Boot集成springfox-swagger,利用Swagger生成JSON API文檔,利用Swagger UI、Postman進行Rest API測試;Angular 6新特性,Angular與Spring Boot、Spring Security、JWT集成的方法;Spring Boot、Angular集成Sonar、Jenkins等。

本文主要參考了Rich Freedman先生的博客"Integrating Angular 2 with Spring Boot, JWT, and CORS",使用了部分代碼(tour-of-heroes-jwt-full),博客地址請見文末參考文檔。前端基於Angular官方樣例Tour of Heroes。完整源碼請從github下載:heroes-api, heroes-web

技術堆棧

  • Spring Boot 2.1.1.RELEASE
  • Spring Security
  • Spring Data
  • Spring Actuator
  • JWT
  • Springfox Swagger 2.9.2
  • Angular 6.0

測試工具: Postman
代碼質量檢查: Sonar
CI: Jenkins
推薦IDE: IntelliJ IDEA、WebStorm/Visual Studio Code

Java代碼中使用了lombok註解,IDE要安裝lombok插件。

Spring Boot

創建Spring Boot App

創建Spring Boot項目最簡易的方式是使用SPRING INITIALIZR
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
輸入Group、Artifact,選擇Dependency(Web、JPA、Security、Actuator、H2、PostgreSQL、Lombok)後,點擊Generate Project,會生成zip包。下載後解壓,編輯POM文件,添加java-jwt和springfox-swagger。我們選用了兩個數據庫H2、PostgreSQL,分別用於開發、測試環境,將其修改到兩個profile dev和prod內。完成的POM文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://×××w.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.itrunner</groupId>
    <artifactId>heroes-api</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <name>heroes</name>
    <description>Demo project for Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <project.profile>dev</project.profile>
        <java.version>1.8</java.version>
    </properties>

    <profiles>
        <profile>
            <id>dev</id>
            <activation/>
            <properties>
                <project.profile>dev</project.profile>
            </properties>
            <dependencies>
                <dependency>
                    <groupId>com.h2database</groupId>
                    <artifactId>h2</artifactId>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
        </profile>

        <profile>
            <id>prod</id>
            <properties>
                <project.profile>prod</project.profile>
            </properties>
            <dependencies>
                <dependency>
                    <groupId>org.postgresql</groupId>
                    <artifactId>postgresql</artifactId>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
        </profile>
    </profiles>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Application配置

Spring Boot可以零配置運行,爲適應不同的環境可添加配置文件application.properties或application.yml來自定義配置、擴展配置。
本文以YML文件爲例:

spring:
  banner:
    charset: utf-8
    image:
      location: classpath:banner.jpg
    location: classpath:banner.txt
  resources:
    add-mappings: true

api:
  base-path: /api

cors:
  allowed-origins: "*"
  allowed-methods: GET,POST,DELETE,PUT,OPTIONS
  allowed-headers: Origin,X-Requested-With,Content-Type,Accept,Accept-Encoding,Accept-Language,Host,Referer,Connection,User-Agent,Authorization

jwt:
  header: Authorization
  secret: mySecret
  expiration: 7200
  issuer: ITRunner
  authentication-path: /auth

springfox:
  documentation:
    swagger:
      v2:
        path: /api-docs

management:
  server:
    port: 8090
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info

info:
  app:
    name: heroes
    version: 1.0

---
spring:
  profiles: dev
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
    show-sql: true
  datasource:
    platform: h2
    initialization-mode: always
server:
  port: 8080

---
spring:
  profiles: prod
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        default_schema: heroes
        format_sql: true
        jdbc:
          lob:
            non_contextual_creation: true
    show-sql: true
  datasource:
    platform: postgresql
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/postgres
    username: hero
    password: hero
    initialization-mode: never
server:
  port: 8000

---
spring:
  profiles:
    active: @project.profile@

配置文件中包含了Banner、Swagger、CORS、JWT、Actuator、Profile等內容,其中active profile使用@project.profile@與pom屬性建立了關聯,這些將在後面的演示中用到。

下面是用來讀取自定義配置的類Config:

package org.itrunner.heroes.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
@ConfigurationProperties
public class Config {

    private Cors cors = new Cors();

    private Jwt jwt = new Jwt();

    // getter & setter

    public static class Cors {
        private List<String> allowedOrigins = new ArrayList<>();

        private List<String> allowedMethods = new ArrayList<>();

        private List<String> allowedHeaders = new ArrayList<>();

        // getter & setter
    }

    public static class Jwt {
        private String header;

        private String secret;

        private Long expiration;

        private String issuer;

        private String authenticationPath;

        // getter & setter
    }
}

自定義Banner

  banner:
    charset: utf-8
    image:
      location: classpath:banner.jpg
    location: classpath:banner.txt
  resources:
    add-mappings: true

Spring Boot啓動時會在控制檯輸出Banner信息,支持文本和圖片。圖片支持gif、jpg、png等格式,會轉換成ASCII碼輸出。

Log配置

Spring Boot Log支持Java Util Logging、 Log4J2、Logback,默認使用Logback。Log可以在application.properties或application.yml中配置。
application.properties:

logging.file=/var/log/heroes.log
logging.level.org.springframework.web=debug

也可以使用單獨的配置文件(放在resources目錄下)
logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="dev">
        <property name="LOG_FILE" value="D:/heroes.log"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_FILE" value="/var/log/heroes.log"/>
    </springProfile>

    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <logger name="root" level="WARN"/>

    <springProfile name="dev">
        <logger name="root" level="INFO"/>
    </springProfile>
        <springProfile name="prod">
        <logger name="root" level="INFO"/>
    </springProfile>
</configuration>

初始化數據

可通過配置指定Spring Boot啓動時是否初始化數據:

  datasource:
    initialization-mode: always

在resources下創建data.sql文件,內容如下:

INSERT INTO HERO(ID, NAME) VALUES(NEXTVAL('HERO_SEQ'), 'Black Widow');
INSERT INTO HERO(ID, NAME) VALUES(NEXTVAL('HERO_SEQ'), 'Superman');
INSERT INTO HERO(ID, NAME) VALUES(NEXTVAL('HERO_SEQ'), 'Rogue');
INSERT INTO HERO(ID, NAME) VALUES(NEXTVAL('HERO_SEQ'), 'Batman');
INSERT INTO HERO(ID, NAME) VALUES(NEXTVAL('HERO_SEQ'), 'Jason');

INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED, LASTPASSWORDRESETDATE) VALUES (NEXTVAL('USER_SEQ'), 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', '[email protected]', TRUE, to_date('01-01-2018', 'dd-MM-yyyy'));
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED, LASTPASSWORDRESETDATE) VALUES (NEXTVAL('USER_SEQ'), 'jason', '$2a$10$6m2VoqZAxa.HJNErs2lZyOFde92PzjPqc88WL2QXYT3IXqZmYMk8i', '[email protected]', TRUE, to_date('01-01-2018','dd-MM-yyyy'));
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED, LASTPASSWORDRESETDATE) VALUES (NEXTVAL('USER_SEQ'), 'fisher', '$2a$10$TBPPC.JbSjH1tuauM8yRauF2k09biw8mUDmYHMREbNSXPWzwY81Ju', '[email protected]', FALSE, to_date('01-01-2018','dd-MM-yyyy'));

INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (NEXTVAL('AUTHORITY_SEQ'), 'ROLE_USER');
INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (NEXTVAL('AUTHORITY_SEQ'), 'ROLE_ADMIN');

INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (1, 1);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (1, 2);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (2, 1);
INSERT INTO USER_AUTHORITY (USER_ID, AUTHORITY_ID) VALUES (3, 1);

說明:

  1. 不同數據庫語法不同時,可創建多個初始化文件,命名格式data-${platform}.sql,比如data-h2.sql、data-postgresql.sql
  2. 密碼與用戶名相同

Domain

在"Tour of Heroes"中使用了angular-in-memory-web-api,此處使用H2嵌入式數據庫取代,增加Hero Domain。
Hero Domain

package org.itrunner.heroes.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import javax.validation.constraints.NotNull;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Hero {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "HERO_SEQ")
    @SequenceGenerator(name = "HERO_SEQ", sequenceName = "HERO_SEQ", allocationSize = 1)
    private Long id;

    @Column(name = "NAME", unique = true, length = 30)
    @NotNull
    private String name;
}

在我們的例子中,包含用戶驗證功能,新增User、Authority Domain:
User Domain

package org.itrunner.heroes.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.Date;
import java.util.List;

@Entity
@Getter
@Setter
@Table(name = "USERS")
public class User {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ")
    @SequenceGenerator(name = "USER_SEQ", sequenceName = "USER_SEQ")
    private Long id;

    @Column(name = "USERNAME", length = 50, unique = true)
    @NotNull
    @Size(min = 4, max = 50)
    private String username;

    @Column(name = "PASSWORD", length = 100)
    @NotNull
    @Size(min = 4, max = 100)
    private String password;

    @Column(name = "EMAIL", length = 50)
    @NotNull
    @Size(min = 4, max = 50)
    private String email;

    @Column(name = "ENABLED")
    @NotNull
    private Boolean enabled;

    @Column(name = "LASTPASSWORDRESETDATE")
    @Temporal(TemporalType.TIMESTAMP)
    @NotNull
    private Date lastPasswordResetDate;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "USER_AUTHORITY", joinColumns = {@JoinColumn(name = "USER_ID", referencedColumnName = "ID")},
            inverseJoinColumns = {@JoinColumn(name = "AUTHORITY_ID", referencedColumnName = "ID")})
    private List<Authority> authorities;
}

Authority Domain

package org.itrunner.heroes.domain;

import lombok.Data;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.List;

@Entity
@Data
@Table(name = "AUTHORITY")
public class Authority {
    @Id
    @Column(name = "ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "AUTHORITY_SEQ")
    @SequenceGenerator(name = "AUTHORITY_SEQ", sequenceName = "AUTHORITY_SEQ")
    private Long id;

    @Column(name = "AUTHORITY_NAME", length = 50)
    @NotNull
    @Enumerated(EnumType.STRING)
    private AuthorityName name;

    @ManyToMany(mappedBy = "authorities", fetch = FetchType.LAZY)
    private List<User> users;
}

AuthorityName

package org.itrunner.heroes.domain;

public enum AuthorityName {
    ROLE_USER, ROLE_ADMIN
}

Repository

JpaRepository提供了常用的方法,僅需增加一些自定義實現:
HeroRepository

package org.itrunner.heroes.repository;

import org.itrunner.heroes.domain.Hero;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface HeroRepository extends JpaRepository<Hero, Long> {

    @Query("select h from Hero h where lower(h.name) like CONCAT('%', lower(:name), '%')")
    List<Hero> findByName(@Param("name") String name);

}

UserRepository

package org.itrunner.heroes.repository;

import org.itrunner.heroes.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    User findByUsername(String username);
}

Service

爲了演示Service的使用,增加了HeroService,在Service層配置了transaction。

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.exception.HeroNotFoundException;
import org.itrunner.heroes.repository.HeroRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
public class HeroService {
    @Autowired
    private HeroRepository repository;

    public Hero getHeroById(Long id) {
        return repository.findById(id).orElseThrow(() -> new HeroNotFoundException(id));
    }

    public List<Hero> getAllHeroes() {
        return repository.findAll();
    }

    public List<Hero> findHeroesByName(String name) {
        return repository.findByName(name);
    }

    public Hero saveHero(Hero hero) {
        return repository.save(hero);
    }

    public void deleteHero(Long id) {
        repository.deleteById(id);
    }
}

Rest Controller

HeroController
演示了GET、POST、PUT、DELETE方法的使用。

package org.itrunner.heroes.controller;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.service.HeroService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping(value = "${api.base-path}", produces = MediaType.APPLICATION_JSON_VALUE)
public class HeroController {
    @Autowired
    private HeroService service;

    @GetMapping("/heroes/{id}")
    public Hero getHeroById(@PathVariable("id") Long id) {
        return service.getHeroById(id);
    }

    @GetMapping("/heroes")
    public List<Hero> getHeroes() {
        return service.getAllHeroes();
    }

    @GetMapping("/heroes/")
    public List<Hero> searchHeroes(@RequestParam("name") String name) {
        return service.findHeroesByName(name);
    }

    @PostMapping("/heroes")
    public Hero addHero(@RequestBody Hero hero) {
        return service.saveHero(hero);
    }

    @PutMapping("/heroes")
    public Hero updateHero(@RequestBody Hero hero) {
        return service.saveHero(hero);
    }

    @DeleteMapping("/heroes/{id}")
    public void deleteHero(@PathVariable("id") Long id) {
        service.deleteHero(id);
    }
}

異常處理

在HeroController中沒有處理異常的代碼,如數據操作失敗會返回什麼結果呢?Spring Boot有全局的異常處理機制,返回ResponseEntity。如添加了重複的記錄,將顯示如下信息:
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
可查看BasicErrorController類的error(HttpServletRequest request)方法跟蹤其是如何處理的:

public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
    HttpStatus status = getStatus(request);
    return new ResponseEntity<>(body, status);
}

顯然返回500錯誤一般是不合適的,錯誤信息也可能需要修改,可使用@ExceptionHandler自定義異常處理機制,如下:

@ExceptionHandler(DataAccessException.class)
public ResponseEntity<Map<String, Object>> handleDataAccessException(DataAccessException exception) {
    LOG.error(exception.getMessage(), exception);
    Map<String, Object> body = new HashMap<>();
    body.put("message", exception.getMessage());
    return ResponseEntity.badRequest().body(body);
}

如@ExceptionHandler中未指定參數將會處理方法參數列表中的所有異常。

對於自定義的異常,可使用@ResponseStatus註解定義code和reason,未定義reason時message將顯示異常信息。

package org.itrunner.heroes.exception;

import org.springframework.web.bind.annotation.ResponseStatus;

import static org.springframework.http.HttpStatus.NOT_FOUND;

@ResponseStatus(code = NOT_FOUND)
public class HeroNotFoundException extends RuntimeException {
    public HeroNotFoundException(Long id) {
        this("Could not find hero with id '%s'", id);
    }

    public HeroNotFoundException(String name) {
        this("Could not find hero with name '%s'", name);
    }

    public HeroNotFoundException(String message, Object... args) {
        super(String.format(message, args));
    }
}

Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
也可以使用@ControllerAdvice定義一個類統一處理Exception,如下:

package org.itrunner.heroes.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice(basePackages = {"org.itrunner.heroes.controller"})
public class ErrorControllerAdvice {
    private static final Logger LOG = LoggerFactory.getLogger(ErrorControllerAdvice.class);

    @ExceptionHandler({
            DuplicateKeyException.class,
            DataIntegrityViolationException.class,
            DataAccessException.class,
            Exception.class
    })
    public ResponseEntity<ErrorMessage> handleException(Exception e) {
        LOG.error(e.getMessage(), e);

        if (e instanceof DuplicateKeyException) {
            return handleMessage("40001", e.getMessage());
        }

        if (e instanceof DataIntegrityViolationException) {
            return handleMessage("40002", e.getMessage());
        }

        if (e instanceof DataAccessException) {
            return handleMessage("40003", e.getMessage());
        }

        return handleMessage("40000", e.getMessage());
    }

    private ResponseEntity<ErrorMessage> handleMessage(String code, String message) {
        return ResponseEntity.badRequest().body(new ErrorMessage(code, message));
    }
}
package org.itrunner.heroes.exception;

import io.swagger.annotations.ApiModel;

@ApiModel
public class ErrorMessage {
    private String code;
    private String message;

    public ErrorMessage() {
    }

    public ErrorMessage(String code, String message) {
        this.code = code;
        this.message = message;
    }

    // getter & setter
}

Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
說明:@RestController內定義的ExceptionHandler優先級更高。

CORS

出於安全原因,瀏覽器限制從腳本內發起的跨源(域或端口)HTTP請求。這意味着Web應用程序(如XMLHttpRequest和Fetch)只能從加載應用程序的同一個域請求HTTP資源。CORS機制允許Web 應用服務器進行跨域訪問控制,從而使跨域數據傳輸得以安全進行。
CORS(Cross-Origin Resource Sharing)
For simple cases like this GET, when your Angular code makes an XMLHttpRequest that the browser determines is cross-origin, the browser looks for an HTTP header named Access-Control-Allow-Origin in the response. If the response header exists, and the value matches the origin domain, then the browser passes the response back to the calling javascript. If the response header does not exist, or it's value does not match the origin domain, then the browser does not pass the response back to the calling code, and you get the error that we just saw.

For more complex cases, like PUTs, DELETEs, or any request involving credentials (which will eventually be all of our requests), the process is slightly more involved. The browser will send an OPTION request to find out what methods are allowed. If the requested method is allowed, then the browser will make the actual request, again passing or blocking the response depending on the Access-Control-Allow-Origin header in the response.

Spring Web支持CORS,只需配置一些參數。因我們引入了Spring Security,這裏我們繼承WebSecurityConfigurerAdapter,先禁用CSRF,不進行用戶驗證。

package org.itrunner.heroes.config;

import org.itrunner.heroes.config.Config.Cors;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@SuppressWarnings("SpringJavaAutowiringInspection")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private Config config;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable().authorizeRequests().anyRequest().permitAll();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        Cors cors = config.getCors();
        configuration.setAllowedOrigins(cors.getAllowedOrigins());
        configuration.setAllowedMethods(cors.getAllowedMethods());
        configuration.setAllowedHeaders(cors.getAllowedHeaders());
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

說明:若前後臺域名不一致,如未集成CORS,前端Angular訪問時將會報如下錯誤:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8080/api/heroes. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing)

啓動Spring Boot

啓動HeroesApplication。

package org.itrunner.heroes;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EnableJpaRepositories(basePackages = {"org.itrunner.heroes.repository"})
@EntityScan(basePackages = {"org.itrunner.heroes.domain"})
public class HeroesApplication {

    public static void main(String[] args) {
        SpringApplication.run(HeroesApplication.class, args);
    }
}

在啓動時可以指定啓用的profile:--spring.profiles.active=dev

Postman測試

Postman是一款非常好用的Restful API測試工具,可保存歷史,可配置環境變量,常和Swagger UI結合使用。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

單元測試與集成測試

單元測試
使用mockito進行單元測試,示例:

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.repository.HeroRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

@RunWith(MockitoJUnitRunner.class)
public class HeroServiceTest {
    @Mock
    private HeroRepository heroRepository;

    @InjectMocks
    private HeroService heroService;

    private List<Hero> heroes;

    @Before
    public void setup() {
        heroes = new ArrayList<>();
        heroes.add(new Hero(1L, "Rogue"));
        heroes.add(new Hero(2L, "Jason"));

        given(heroRepository.findById(1L)).willReturn(Optional.of(heroes.get(0)));
        given(heroRepository.findAll()).willReturn(heroes);
        given(heroRepository.findByName("o")).willReturn(heroes);
    }

    @Test
    public void getHeroById() {
        Hero hero = heroService.getHeroById(1L);
        assertThat(hero.getName()).isEqualTo("Rogue");
    }

    @Test
    public void getAllHeroes() {
        List<Hero> heroes = heroService.getAllHeroes();
        assertThat(heroes.size()).isEqualTo(2);
    }

    @Test
    public void findHeroesByName() {
        List<Hero> heroes = heroService.findHeroesByName("o");
        assertThat(heroes.size()).isEqualTo(2);
    }
}

集成測試
使用@RunWith(SpringRunner.class)和@SpringBootTest進行集成測試,使用TestRestTemplate來調用Rest Api。
@SpringBootTest的webEnvironment屬性有以下可選值:

  • MOCK: Loads a WebApplicationContext and provides a mock servlet environment. Embedded servlet containers are not started when using this annotation.
  • RANDOM_PORT: Loads an ServletWebServerApplicationContext and provides a real servlet environment. Embedded servlet containers are started and listen on a random port.
  • DEFINED_PORT: Loads a ServletWebServerApplicationContext and provides a real servlet environment. Embedded servlet containers are started and listen on a defined port (from your application.properties or on the default port of 8080).
  • NONE: Loads an ApplicationContext by using SpringApplication but does not provide any servlet environment.

當進行集成測試時,推薦使用RANDOM_PORT,這樣會隨機選擇一個可用端口。

package org.itrunner.heroes;

import org.itrunner.heroes.domain.Hero;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class HeroesApplicationTests {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void add_update_find_delete_hero() {
        Hero hero = new Hero();
        hero.setName("Jack");

        // add hero
        hero = restTemplate.postForObject("/api/heroes", hero, Hero.class);
        assertThat(hero.getId()).isNotNull();

        // update hero
        hero.setName("Jacky");
        HttpEntity<Hero> requestEntity = new HttpEntity<>(hero);
        hero = restTemplate.exchange("/api/heroes", HttpMethod.PUT, requestEntity, Hero.class).getBody();
        assertThat(hero.getName()).isEqualTo("Jacky");

        // find heroes by name
        Map<String, String> urlVariables = new HashMap<>();
        urlVariables.put("name", "m");
        List<Hero> heroes = restTemplate.getForObject("/api/heroes/?name={name}", List.class, urlVariables);
        assertThat(heroes.size()).isEqualTo(2);

        // get hero by id
        hero = restTemplate.getForObject("/api/heroes/" + hero.getId(), Hero.class);
        assertThat(hero.getName()).isEqualTo("Jacky");

        // delete hero successfully
        ResponseEntity<String> response = restTemplate.exchange("/api/heroes/" + hero.getId(), HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(200);

        // delete hero
        response = restTemplate.exchange("/api/heroes/9999", HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(400);
    }
}

Actuator監控

Actuator用來監控和管理Spring Boot應用,支持很多的endpoint。

ID Description JMX Default Exposure Web Default Exposure
beans Exposes audit events information for the current application Yes No
auditevents Displays a complete list of all the Spring beans in your application Yes No
conditions Shows the conditions that were evaluated on configuration and auto-configuration classes and the reasons why they did or did not match Yes No
configprops Displays a collated list of all @ConfigurationProperties Yes No
env Exposes properties from Spring’s ConfigurableEnvironment Yes No
flyway Shows any Flyway database migrations that have been applied Yes No
health Shows application health information Yes Yes
httptrace Displays HTTP trace information (by default, the last 100 HTTP request-response exchanges) Yes No
info Displays arbitrary application info Yes Yes
loggers Shows and modifies the configuration of loggers in the application Yes No
liquibase Shows any Liquibase database migrations that have been applied Yes No
metrics Shows ‘metrics’ information for the current application Yes No
mappings Displays a collated list of all @RequestMapping paths Yes No
scheduledtasks Displays the scheduled tasks in your application Yes No
sessions Allows retrieval and deletion of user sessions from a Spring Session-backed session store Yes No
shutdown Lets the application be gracefully shutdown Yes No
threaddump Performs a thread dump Yes No

爲了啓用Actuator需要增加以下dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

默認訪問Actuator需要驗證,端口與application相同,base-path爲/actuator(即訪問endpoint時的前置路徑),這些都可以配置,application info信息也可以配置。

management:
  server:
    port: 8090
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always

info:
  app:
    name: heroes
    version: 1.0

在WebSecurityConfig configure(HttpSecurity http)方法中增加權限配置:

.authorizeRequests()
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()

默認,除shutdown外所有endpoint都是啓用的,啓用shutdown的配置如下:

management.endpoint.shutdown.enabled=true

也可以禁用所有的endpoint,只啓用你需要的:

management.endpoints.enabled-by-default=false
management.endpoint.info.enabled=true

訪問URL:http://localhost:8090/actuator/health http://localhost:8090/actuator/info ,更多信息請查閱Spring Boot文檔。

Sonar集成

增加如下plugin配置:

<plugins>
    <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>sonar-maven-plugin</artifactId>
        <version>3.4.1.1168</version>
    </plugin>
    <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.1</version>
        <configuration>
            <destFile>${project.build.directory}/jacoco.exec</destFile>
            <dataFile>${project.build.directory}/jacoco.exec</dataFile>
        </configuration>
        <executions>
            <execution>
                <goals>
                    <goal>prepare-agent</goal>
                </goals>
            </execution>
        </executions>
    </plugin>
</plugins>

爲生成測試報告需要使用jacoco-maven-plugin。生成Sonar報告的命令如下:

mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test sonar:sonar

CI集成

Jenkins支持pipeline後大大簡化了任務配置,使用Jenkinsfile定義pipeline並提交到SCM,項目成員修改CI流程後Jenkins能自動同步。以下是簡單的Jenkinsfile示例:

node {
    checkout scm
    stage('Test') {
        bat 'mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test'
    }
    stage('Sonar') {
        bat 'mvn sonar:sonar'
    }
    stage('Package') {
        bat 'mvn clean package -Dmaven.test.skip=true'
    }
}

Jenkinsfile文件一般放在項目根目錄下(文件命名爲Jenkinsfile)。Pipeline支持聲明式和Groovy兩種語法,聲明式更簡單,Groovy更靈活。例子使用的是Groovy語法,適用於windows環境(linux將bat改爲sh),詳細的介紹請查看Pipeline Syntax
在創建Jenkins任務時選擇Pipeline(流水線)類型,然後在定義pipeline時選擇“Pipeline script from SCM”,配置好SCM後填寫Pipeline路徑即可。

集成Spring Security與JWT

JWT

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA.

JSON Web Token由三部分組成:

  • Header 包含token類型與算法
  • Payload 包含三種Claim: registered、public、private。
    Registered包含一些預定義的claim:iss (issuer)、 sub (subject)、aud (audience)、exp (expiration time)、nbf(Not Before)、iat (Issued At)、jti(JWT ID)
    Public 可以隨意定義,但爲避免衝突,應使用IANA JSON Web Token Registry 中定義的名稱,或將其定義爲包含namespace的URI以防命名衝突。
    Private 非registered或public claim,各方之間共享信息而創建的定製聲明。
  • Signature

生成的JWT Base64字符串以點分隔,格式如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiaXNzIjoidGVzdCIsImV4cCI6MTUxOTQ2MzYyMCwiaWF0IjoxNTE5NDU2NDIwfQ.lWyU0c0r2lh8f8pzETfmvGWaPpBixOUsHJ9Q2mPQyaI

JWT用於用戶驗證時,Payload至少要包含User ID和expiration time。

驗證流程
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
瀏覽器收到JWT後將其保存在local storage中,當訪問受保護資源時在header中添加token,通常使用Bearer Token格式:

Authorization: Bearer <token>

JWT驗證機制是無狀態的,Server並不保存用戶狀態。JWT包含了所有必要的信息,減少了查詢數據庫的需求。

示例使用的是Auth0 Open Source API - java-jwt
說明:

  • Auth0 implements proven, common and popular identity protocols used in consumer oriented web products (OAuth 2.0, OpenID Connect) and in enterprise deployments (SAML, WS-Federation, LDAP).
  • OAuth 2.0 is an authorization framework that enables a third-party application to obtain limited access to resources the end-user owns.

創建和驗證JWT Token

JWT支持HMAC、RSA、ECDSA算法。其中HMAC使用secret,RSA、ECDSA使用key pairs或KeyProvider,私鑰用於簽名,公鑰用於驗證。當使用KeyProvider時可以在運行時更改私鑰或公鑰。
示例
使用HS256創建Token

Algorithm algorithm = Algorithm.HMAC256("secret");
String token = JWT.create().withIssuer("auth0").sign(algorithm);

使用RS256創建Token

RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
String token = JWT.create().withIssuer("auth0").sign(algorithm);

使用HS256驗證Token

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
Algorithm algorithm = Algorithm.HMAC256("secret");
JWTVerifier verifier = JWT.require(algorithm).withIssuer("auth0").build(); 
DecodedJWT jwt = verifier.verify(token);

使用RS256驗證Token

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
RSAPublicKey publicKey = //Get the key instance
RSAPrivateKey privateKey = //Get the key instance
Algorithm algorithm = Algorithm.RSA256(publicKey, privateKey);
JWTVerifier verifier = JWT.require(algorithm).withIssuer("auth0").build(); 
DecodedJWT jwt = verifier.verify(token);

JwtTokenUtil
示例使用了HMAC算法來生成和驗證token,token中保存了用戶名和Authority(驗證權限時不必再訪問數據庫了),代碼如下:

package org.itrunner.heroes.util;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.itrunner.heroes.config.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class JwtTokenUtil {
    private static final Log LOG = LogFactory.getLog(JwtTokenUtil.class);

    private static final String CLAIM_AUTHORITIES = "authorities";

    @Autowired
    private Config config;

    public String generate(UserDetails user) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(config.getJwt().getSecret());
            return JWT.create()
                    .withIssuer(config.getJwt().getIssuer())
                    .withIssuedAt(new Date())
                    .withExpiresAt(new Date(System.currentTimeMillis() + config.getJwt().getExpiration() * 1000))
                    .withSubject(user.getUsername())
                    .withArrayClaim(CLAIM_AUTHORITIES, AuthorityUtil.getAuthorities(user))
                    .sign(algorithm);
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    /**
     * @param token
     * @return username
     */
    public UserDetails verify(String token) {
        if (token == null) {
            return null;
        }

        try {
            Algorithm algorithm = Algorithm.HMAC256(config.getJwt().getSecret());
            JWTVerifier verifier = JWT.require(algorithm).withIssuer(config.getJwt().getIssuer()).build();
            DecodedJWT jwt = verifier.verify(token);
            return new User(jwt.getSubject(), "N/A", AuthorityUtil.createGrantedAuthorities(jwt.getClaim(CLAIM_AUTHORITIES).asArray(String.class)));
        } catch (Exception e) {
            LOG.error(e);
            return null;
        }
    }
}

AuthorityUtil(UserDetails Authority轉換工具類)

package org.itrunner.heroes.util;

import org.itrunner.heroes.domain.Authority;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public final class AuthorityUtil {

    private AuthorityUtil() {
    }

    public static List<GrantedAuthority> createGrantedAuthorities(List<Authority> authorities) {
        return authorities.stream().map(authority -> new SimpleGrantedAuthority(authority.getName().name())).collect(Collectors.toList());
    }

    public static List<GrantedAuthority> createGrantedAuthorities(String... authorities) {
        return Stream.of(authorities).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    public static String[] getAuthorities(UserDetails user) {
        return user.getAuthorities().stream().map(GrantedAuthority::<String>getAuthority).toArray(String[]::new);
    }
}

UserDetailsService

實現Spring Security的UserDetailsService,從數據庫獲取用戶數據,其中包括用戶名、密碼、權限。UserDetailsService用於用戶名/密碼驗證和生成token,將在後面的WebSecurityConfig和AuthenticationController中使用。

package org.itrunner.heroes.service;

import org.itrunner.heroes.domain.User;
import org.itrunner.heroes.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
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;

import static org.itrunner.heroes.util.AuthorityUtil.createGrantedAuthorities;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(String.format("No user found with username '%s'.", username)));
        return create(user);
    }

    private static org.springframework.security.core.userdetails.User create(User user) {
        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), createGrantedAuthorities(user.getAuthorities()));
    }
}

JWT驗證Filter

從Request Header中讀取Bearer Token並驗證,如驗證成功則將用戶信息保存在SecurityContext中,用戶則可以訪問受限資源了。在每次請求結束後,SecurityContext會自動清空。
AuthenticationTokenFilter

package org.itrunner.heroes.config;

import org.itrunner.heroes.util.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class AuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private Config config;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String authToken = request.getHeader(config.getJwt().getHeader());

        if (authToken != null && authToken.startsWith("Bearer ")) {
            authToken = authToken.substring(7);
        }

        UserDetails user = jwtTokenUtil.verify(authToken);

        if (user != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            logger.info("checking authentication for user " + user.getUsername());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user.getUsername(), "N/A", user.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
}

AuthenticationEntryPoint

我們沒有使用form或basic等驗證機制,需要自定義一個AuthenticationEntryPoint,當未驗證用戶訪問受限資源時,返回401錯誤。如沒有自定義AuthenticationEntryPoint,將返回403錯誤。使用方法見WebSecurityConfig。

package org.itrunner.heroes.config;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // This is invoked when user tries to access a secured REST resource without supplying any credentials
        // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to
        response.sendError(UNAUTHORIZED.value(), UNAUTHORIZED.getReasonPhrase());
    }
}

WebSecurityConfig

在WebSecurityConfig中配置UserDetailsService、Filter、AuthenticationEntryPoint、需要驗證的request,定義密碼加密算法。

package org.itrunner.heroes.config;

import org.itrunner.heroes.config.Config.Cors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import static org.springframework.http.HttpMethod.*;

@Configuration
@EnableWebSecurity
@SuppressWarnings("SpringJavaAutowiringInspection")
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private static final String ROLE_ADMIN = "ADMIN";

    @Value("${api.base-path}/**")
    private String apiPath;

    @Value("${management.endpoints.web.exposure.include}")
    private String[] actuatorExposures;

    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    @Autowired
    private Config config;

    @Autowired
    private UserDetailsService userDetailsService;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() // don't create session
                .authorizeRequests()
                .requestMatchers(EndpointRequest.to(actuatorExposures)).permitAll()
                .antMatchers(config.getJwt().getAuthenticationPath()).permitAll()
                .antMatchers(OPTIONS, "/**").permitAll()
                .antMatchers(POST, apiPath).hasRole(ROLE_ADMIN)
                .antMatchers(PUT, apiPath).hasRole(ROLE_ADMIN)
                .antMatchers(DELETE, apiPath).hasRole(ROLE_ADMIN)
                .anyRequest().authenticated().and()
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class) // Custom JWT based security filter
                .headers().cacheControl(); // disable page caching
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public AuthenticationTokenFilter authenticationTokenFilterBean() {
        return new AuthenticationTokenFilter();
    }

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

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        Cors cors = config.getCors();
        configuration.setAllowedOrigins(cors.getAllowedOrigins());
        configuration.setAllowedMethods(cors.getAllowedMethods());
        configuration.setAllowedHeaders(cors.getAllowedHeaders());
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

說明:

  • 在Spring Boot 2.0中必須覆蓋authenticationManagerBean()方法,否則在@Autowired authenticationManager時會報錯:Field authenticationManager required a bean of type 'org.springframework.security.authentication.AuthenticationManager' that could not be found.
  • 在初始化數據時的密碼是調用new BCryptPasswordEncoder().encode()方法生成的。
  • POST\PUT\DELETE請求需要"ADMIN"角色。調用hasRole()方法時應去掉前綴"ROLE_",方法會自動補充,否則請使用hasAuthority()。

Authentication Controller

AuthenticationController
驗證用戶名、密碼,驗證成功則返回Token和Authority。

package org.itrunner.heroes.controller;

import io.swagger.annotations.Api;
import org.itrunner.heroes.util.JwtTokenUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@Api(tags = {"Authentication Controller"})
public class AuthenticationController {
    private static final Logger LOG = LoggerFactory.getLogger(AuthenticationController.class);

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Autowired
    private UserDetailsService userDetailsService;

    @PostMapping(value = "${jwt.authentication-path}")
    public AuthenticationResponse login(@RequestBody AuthenticationRequest request) {
        // Perform the security
        final Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // Reload password post-security so we can generate token
        final UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
        final String token = jwtTokenUtil.generate(userDetails);

        // Return the token
        return new AuthenticationResponse(token, AuthorityUtils.authorityListToSet(userDetails.getAuthorities()));
    }

    @ExceptionHandler(AuthenticationException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public void handleAuthenticationException(AuthenticationException exception) {
        LOG.error(exception.getMessage(), exception);
    }
}

AuthenticationRequest

package org.itrunner.heroes.controller;

public class AuthenticationRequest {
    private String username;
    private String password;

    // getter & setter
}

AuthenticationResponse

package org.itrunner.heroes.controller;

import java.util.Set;

public class AuthenticationResponse {
    private String token;
    private Set<String> authorities;

    public AuthenticationResponse() {
    }

    public AuthenticationResponse(String token, Set<String> authorities) {
        this.token = token;
        this.authorities = authorities;
    }

    // getter & setter
}

重啓Spring Boot,用postman來測試一下,輸入驗證URL:localhost:8080/auth、正確的用戶名和密碼,提交後會輸出token。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
此時如再請求localhost:8080/api/heroes將會收到403錯誤,將token填入到Authorization header中,可以查詢出hero。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
用戶"admin"可以執行CRUD操作,"jason"只有查詢權限。

更新集成測試

啓用用戶驗證後,執行集成測試前要先登錄獲取token,並添加到request header中,增加如下代碼:

    @Before
    public void setup() {
        AuthenticationRequest authenticationRequest = new AuthenticationRequest();
        authenticationRequest.setUsername("admin");
        authenticationRequest.setPassword("admin");
        token = restTemplate.postForObject("/auth", authenticationRequest, AuthenticationResponse.class).getToken();

        restTemplate.getRestTemplate().setInterceptors(
                Collections.singletonList((request, body, execution) -> {
                    HttpHeaders headers = request.getHeaders();
                    headers.add("Authorization", "Bearer " + token);
                    headers.add("Content-Type", "application/json");
                    return execution.execute(request, body);
                }));
    }

也可以針對某一請求添加token,如下:

// update hero
hero.setName("Jacky");
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("Authorization", "Bearer " + token);
HttpEntity<Hero> requestEntity = new HttpEntity<>(hero, httpHeaders);
hero = restTemplate.exchange("/api/heroes", HttpMethod.PUT, requestEntity, Hero.class).getBody();
assertThat(hero.getName()).isEqualTo("Jacky");

集成Swagger

啓用Swagger

啓用Swagger非常簡單,僅需編寫一個類:

package org.itrunner.heroes.config;

import com.fasterxml.classmate.TypeResolver;
import org.itrunner.heroes.exception.ErrorMessage;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.*;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.time.LocalDate;
import java.util.List;

import static com.google.common.collect.Lists.newArrayList;

@EnableSwagger2
@Configuration
public class SwaggerConfig {

    @Bean
    public Docket petApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.basePackage("org.itrunner.heroes.controller"))
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo())
                .pathMapping("/")
                .directModelSubstitute(LocalDate.class, String.class)
                .genericModelSubstitutes(ResponseEntity.class)
                .additionalModels(new TypeResolver().resolve(ErrorMessage.class))
                .useDefaultResponseMessages(false)
                .securitySchemes(newArrayList(apiKey()))
                .securityContexts(newArrayList(securityContext()))
                .enableUrlTemplating(false);
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Api Documentation")
                .description("Api Documentation")
                .contact(new Contact("Jason", "https://blog.51cto.com/7308310", "[email protected]"))
                .version("1.0")
                .build();
    }

    private ApiKey apiKey() {
        return new ApiKey("BearerToken", "Authorization", "header");  // 用於Swagger UI測試時添加Bearer Token
    }

    private SecurityContext securityContext() {
        return SecurityContext.builder()
                .securityReferences(defaultAuth())
                .forPaths(PathSelectors.regex("/api/.*")) // 注意要與Restful API路徑一致
                .build();
    }

    List<SecurityReference> defaultAuth() {
        AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
        AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
        authorizationScopes[0] = authorizationScope;
        return newArrayList(new SecurityReference("BearerToken", authorizationScopes));
    }
}

然後在WebSecurityConfig中配置不需驗證的URI:

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api-docs", "/swagger-resources/**", "/swagger-ui.html**", "/webjars/**");
    }

spring.resources.add-mappings要設爲true,api-docs路徑可自定義:

spring:
  resources:
    add-mappings: true

springfox:
  documentation:
    swagger:
      v2:
        path: /api-docs

訪問Api doc: http://localhost:8080/api-docs
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
訪問Swagger UI: http://localhost:8080/swagger-ui.html
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

API Doc

在以前的HeroController代碼中未進行API Doc配置,文檔會自動生成,可添加Annotation定義更詳細的文檔內容。

package org.itrunner.heroes.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.service.HeroService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping(value = "/api", produces = MediaType.APPLICATION_JSON_VALUE)
@Api(tags = {"Hero Controller"})
public class HeroController {
    private static final Logger LOG = LoggerFactory.getLogger(HeroController.class);

    @Autowired
    private HeroService service;

    @ApiOperation(value = "Get hero by id")
    @GetMapping("/heroes/{id}")
    public Hero getHeroById(@ApiParam(required = true, example = "1") @PathVariable("id") Long id) {
        return service.getHeroById(id);
    }

    @ApiOperation(value = "Get all heroes")
    @GetMapping("/heroes")
    public List<Hero> getHeroes() {
        return service.getAllHeroes();
    }

    @ApiOperation(value = "Search heroes by name")
    @GetMapping("/heroes/")
    public List<Hero> searchHeroes(@ApiParam(required = true) @RequestParam("name") String name) {
        return service.findHeroesByName(name);
    }

    @ApiOperation(value = "Add new hero")
    @PostMapping("/heroes")
    public Hero addHero(@ApiParam(required = true) @RequestBody Hero hero) {
        return service.saveHero(hero);
    }

    @ApiOperation(value = "Update hero info")
    @PutMapping("/heroes")
    public Hero updateHero(@ApiParam(required = true) @RequestBody Hero hero) {
        return service.saveHero(hero);
    }

    @ApiOperation(value = "Delete hero by id")
    @DeleteMapping("/heroes/{id}")
    public void deleteHero(@ApiParam(required = true, example = "1") @PathVariable("id") Long id) {
        service.deleteHero(id);
    }

    @ExceptionHandler(DataAccessException.class)
    public ResponseEntity<Map<String, Object>> handleDataAccessException(DataAccessException exception) {
        LOG.error(exception.getMessage(), exception);
        Map<String, Object> body = new HashMap<>();
        body.put("message", exception.getMessage());
        return ResponseEntity.badRequest().body(body);
    }
}

API Model
API使用的model類,可以使用@ApiModel、@ApiModelProperty註解,在使用Swagger UI測試時,example是默認值。

package org.itrunner.heroes.controller;

import io.swagger.annotations.ApiModelProperty;

public class AuthenticationRequest {
    @ApiModelProperty(value = "username", example = "admin", required = true)
    private String username;

    @ApiModelProperty(value = "password", example = "admin", required = true)
    private String password;

    // getter & setter
}

Swagger UI測試

使用Swagger UI測試有以下優點:

  • 可直接點選要測試的API
  • 提供需要的參數和默認值,只需編輯參數值
  • 只需一次認證
  • 直觀的顯示Request和Response信息

Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
先測試auth api來獲取token,點擊Try it out,然後輸入username和password,點擊Excute,成功後會輸出token。
下一步進行驗證,點擊頁面上方的Authorize,輸入token,驗證後就可以進行其他測試了。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

Angular

Angular 5.0新特性

  • 編譯性能提升,AOT編譯速度加快,已成爲開發中推薦的編譯方式
  • i18n支持新的number、 date、 currency pipes
  • 使用StaticInjector替代ReflectiveInjector,不再需要Reflect polyfill,減少app大小。
  • exportAs支持多個名字
  • 原@angular/http模塊已過期,推薦使用HttpClient
  • 新增Router Lifecycle Events
    GuardsCheckStart, ChildActivationStart, ActivationStart, GuardsCheckEnd, ResolveStart, ResolveEnd, ActivationEnd, ChildActivationEnd
  • 語法檢查更嚴格

Angular 6.0新特性

  • CLI Workspaces
    CLI v6支持包含多項目的workspace,使用angular.json代替了.angular-cli.json,詳細內容請查看angular.json。
  • Angular CLI更新
    新增ng add、ng update、ng generate library
    ng add <collection> Add support for a library to your project
    ng update <packages> [options] Updates your application and its dependencies
    ng generate library <name> create a library project within your CLI workspace
  • Tree Shakable Providers
    從module引用服務改爲服務引用模塊,使得應用更小。

之前:

@NgModule({
  ...
  providers: [MyService]
})
export class AppModule {}
import { Injectable } from '@angular/core';

@Injectable()
export class MyService {
  constructor() { }
}

現在:

import { Injectable } from '@angular/core';

@Injectable({providedIn: 'root'})
export class MyService {
  constructor() { }
}
  • Angular Material Starter Components
    運行ng add @angular/material,生成3個新的starter component:
    Material Sidenav
    Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
    Material Dashboard
    Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
    Material Data Table
    Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
  • Angular Elements
  • Animations Performance Improvements
  • RxJS v6

配置開發環境

  • 安裝Node.js8.x或以上版本
  • npm版本要求5.x或以上,如版本低請更新:
npm i npm@latest -g
  • 安裝Angular CLI
npm install -g @angular/cli@latest

更新Tour of Heroes

Tour of Heroes使用了“in-memory-database”,我們要刪除相關內容改爲調用Spring Boot Rest API。

  1. 刪除in-memory-data.service.ts,刪除app.module.ts中的InMemoryDataService、HttpClientInMemoryWebApiModule。package.json中的“angular-in-memory-web-api”也可刪除。
  2. 配置environment,編輯environment.ts、environment.prod.ts,內容如下:

environment.ts

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080'
};

environment.prod.ts

export const environment = {
  production: true,
  apiUrl: 'http://localhost:8080' // 修改爲實際IP
};
  1. 編輯hero.service.ts將“api/heroes”替換爲"${environment.apiUrl}/api/heroes" :
import {environment} from '../environments/environment';
...
private heroesUrl = `${environment.apiUrl}/api/heroes`;
  1. 修改add hero代碼

Hero domain設定name不能重複,如添加重複記錄,原代碼會出現問題,修改如下:
heroes.component.ts

add(name: string): void {
  name = name.trim();
  if (!name) { return; }
  this.heroService.addHero({ name } as Hero)
    .subscribe(hero => {
      if(hero) {
        this.heroes.push(hero);
      }
    });
}

hero.service.ts

addHero (hero: Hero): Observable<Hero> {
  return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
    tap((hero: Hero) => {
        if (hero) {
          this.log(`added hero w/ id=${hero.id}`)
        }
      }
    ),
    catchError(this.handleError<Hero>('addHero'))
  );
}
  1. 顯示錯誤信息,修改如下:

hero.service.ts

private handleError<T>(operation = 'operation', result?: T) {
  return (response: any): Observable<T> => {
    console.error(response.error); // log to console instead

    this.log(`${operation} failed: ${response.error.message}`);

    // Let the app keep running by returning an empty result.
    return of(result as T);
  };
}

當添加重複記錄時,顯示如下信息:
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

  1. 安裝、啓動Angular:
npm install
ng serve

測試:
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
此時訪問,頁面輸出以下錯誤:
HeroService: getHeroes failed: Http failure response for http://localhost:8080/api/heroes: 403 OK

Authentication Service

AuthenticationService請求http://localhost:8080/auth 驗證用戶,如驗證成功則在localStorage中保存用戶token和Authority。

import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {Observable, of} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {environment} from '../environments/environment';

const httpOptions = {
  headers: new HttpHeaders({'Content-Type': 'application/json'})
};

@Injectable({providedIn: 'root'})
export class AuthenticationService {

  constructor(private http: HttpClient) {
  }

  login(username: string, password: string): Observable<boolean> {
    return this.http.post<any>(`${environment.apiUrl}/auth`, JSON.stringify({username: username, password: password}), httpOptions).pipe(
      tap(response => {
        if (response && response.token) {
          // login successful, store username and jwt token in local storage to keep user logged in between page refreshes
          localStorage.setItem('currentUser', JSON.stringify({username: username, token: response.token, authorities: response.authorities}));
          return of(true);
        } else {
          return of(false);
        }
      }),
      catchError((err) => {
        console.error(err);
        return of(false)
      })
    );
  }

  getCurrentUser(): any {
    const userStr = localStorage.getItem('currentUser');
    return userStr ? JSON.parse(userStr) : '';
  }

  getToken(): string {
    const currentUser = this.getCurrentUser();
    return currentUser ? currentUser.token : '';
  }

  getUsername(): string {
    const currentUser = this.getCurrentUser();
    return currentUser ? currentUser.username : '';
  }

  logout(): void {
    localStorage.removeItem('currentUser');
  }

  isLoggedIn(): boolean {
    const token: String = this.getToken();
    return token && token.length > 0;
  }

  hasRole(role: string): boolean {
    const currentUser = this.getCurrentUser();
    if (!currentUser) {
      return false;
    }
    const authorities: string[] = currentUser.authorities;
    return authorities.indexOf('ROLE_' + role) != -1;
  }
}

創建登錄頁面

在src\app下新建login目錄,然後增加組件:
login.component.ts
LoginComponent調用AuthenticationService,如驗證成功則跳轉到dashboard頁面,否則顯示錯誤信息。

import {Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {AuthenticationService} from '../authentication.service';
import {MessageService} from '../message.service';

@Component({
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})

export class LoginComponent implements OnInit {
  model: any = {};
  loading = false;

  constructor(private router: Router, private authenticationService: AuthenticationService, private messageService: MessageService) {
  }

  ngOnInit() {
    // reset login status
    this.authenticationService.logout();
  }

  login() {
    this.loading = false;
    this.authenticationService.login(this.model.username, this.model.password)
      .subscribe(result => {
        if (result) {
          // login successful
          this.loading = true;
          this.router.navigate(['dashboard']);
        } else {
          // login failed
          this.log('Username or password is incorrect');
        }
      });
  }

  private log(message: string) {
    this.messageService.add('Login: ' + message);
  }
}

login.component.html

<div class="col-md-6 col-md-offset-3">
  <h2>Login</h2>

  <div class="alert alert-info">
    Username: admin<br/>
    Password: admin
  </div>

  <form name="form" #f="ngForm" novalidate>
    <div class="form-group" [ngClass]="{ 'has-error': f.submitted && !username.valid }">
      <label for="username">Username</label>
      <input type="text" class="form-control" id="username" name="username" [(ngModel)]="model.username" #username="ngModel" required/>
      <span *ngIf="f.submitted && !username.valid" class="help-block">Username is required</span>
    </div>
    <div class="form-group" [ngClass]="{ 'has-error': f.submitted && !password.valid }">
      <label for="password">Password </label>
      <input type="password" class="form-control" id="password" name="password" [(ngModel)]="model.password" #password="ngModel" required/>
      <span *ngIf="f.submitted && !password.valid" class="help-block">Password is required</span>
    </div>
    <div class="form-group">
      <button [disabled]="loading" class="btn btn-primary" (click)="login()">Login</button>
      <img *ngIf="loading"
           src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA=="/>
    </div>
  </form>
</div>

login.component.css

.alert {
  width: 200px;
  margin-top: 20px;
  margin-bottom: 20px;
}

.alert.alert-info {
  color: #607D8B;
}

.alert.alert-error {
  color: red;
}

.help-block {
  width: 200px;
  color: white;
  background-color: gray;
}

.form-control {
  width: 200px;
  margin-bottom: 10px;
}

.btn {
  margin-top: 20px;
}

在app.module.ts中添加LoginComponent:

  declarations: [
    AppComponent,
    DashboardComponent,
    HeroesComponent,
    HeroDetailComponent,
    MessagesComponent,
    HeroSearchComponent,
    LoginComponent
  ]

接下來,編輯app.component.html,添加login鏈接

<h1>{{title}}</h1>
<nav>
  <a routerLink="/login">Login</a>
  <a routerLink="/dashboard">Dashboard</a>
  <a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>

保護你的資源

完成登錄頁面,那如何防止未登錄用戶訪問其他頁面呢,使用Auth Guard。
CanActivateAuthGuard

import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {AuthenticationService} from './authentication.service';

@Injectable({providedIn: 'root'})
export class CanActivateAuthGuard implements CanActivate {

  constructor(private router: Router, private authService: AuthenticationService) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    if (this.authService.isLoggedIn()) {
      // logged in so return true
      return true;
    }

    // not logged in so redirect to login page with the return url and return false
    this.router.navigate(['/login']);
    return false;
  }
}

CanActivateAuthGuard調用AuthenticationService,檢查用戶是否登錄,如未登錄則跳轉到login頁面。
然後在app-routing.module.ts中給受保護頁面配置CanActivateAuthGuard,並添加login組件。

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';

import {DashboardComponent} from './dashboard/dashboard.component';
import {HeroesComponent} from './heroes/heroes.component';
import {HeroDetailComponent} from './hero-detail/hero-detail.component';
import {LoginComponent} from './login/login.component';
import {CanActivateAuthGuard} from './can-activate.authguard';

const routes: Routes = [
  {path: '', redirectTo: '/dashboard', pathMatch: 'full'},
  {path: 'login', component: LoginComponent},
  {path: 'dashboard', component: DashboardComponent, canActivate: [CanActivateAuthGuard]},
  {path: 'detail/:id', component: HeroDetailComponent, canActivate: [CanActivateAuthGuard]},
  {path: 'heroes', component: HeroesComponent, canActivate: [CanActivateAuthGuard]}
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {
}

添加Bearer Token

如何將JWT Token添加到header中呢?
一種方式是在http請求中添加httpOptions。

const httpOptions = {
  headers: new HttpHeaders({
    'Content-Type': 'application/json'}),
    'Authorization': 'Bearer ' + this.authenticationService.getToken()
};

另一種方式使用HttpInterceptor

import {Injectable} from '@angular/core';
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {Observable} from 'rxjs';

@Injectable()
export class AuthenticationInterceptor implements HttpInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const idToken = this.getToken();

    if (idToken) {
      const cloned = req.clone({
        headers: req.headers.set('Authorization', 'Bearer ' + idToken)
      });

      return next.handle(cloned);
    } else {
      return next.handle(req);
    }
  }

  getToken(): string {
    const userStr = localStorage.getItem('currentUser');
    return userStr ? JSON.parse(userStr).token : '';
  }
}

HttpInterceptor會自動在所有http請求中添加token。HttpInterceptor需要在app.module.ts中註冊

  providers: [
    [{provide: HTTP_INTERCEPTORS, useClass: AuthenticationInterceptor, multi: true}]
  ],

權限控制

新增一個directive,用於根據用戶角色顯示頁面元素。
HasRoleDirective

import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import {AuthenticationService} from './authentication.service';

@Directive({
  selector: '[appHasRole]'
})
export class HasRoleDirective {
  constructor(private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef, private authenticationService: AuthenticationService) {
  }

  @Input()
  set appHasRole(role: string) {
    if (this.authenticationService.hasRole(role)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

將HasRoleDirective添加到AppModule的declarations中。
修改heroes.component.html和hero-detail.component.html,使用appHasRole:
heroes.component.html

<h2>My Heroes</h2>

<div *appHasRole="'ADMIN'">
  <label>Hero name:
    <input #heroName />
  </label>
  <!-- (click) passes input value to add() and then clears the input -->
  <button (click)="add(heroName.value); heroName.value=''">
    add
  </button>
</div>

<ul class="heroes">
  <li *ngFor="let hero of heroes">
    <a routerLink="/detail/{{hero.id}}">
      <span class="badge">{{hero.id}}</span> {{hero.name}}
    </a>
    <button class="delete" title="delete hero" (click)="delete(hero)" *appHasRole="'ADMIN'">x</button>
  </li>
</ul>

hero-detail.component.html

<div *ngIf="hero">
  <h2>{{hero.name | uppercase}} Details</h2>
  <div><span>id: </span>{{hero.id}}</div>
  <div>
    <label>name:
      <input [(ngModel)]="hero.name" placeholder="name"/>
    </label>
  </div>
  <button (click)="goBack()">go back</button>
  <button (click)="save()" *appHasRole="'ADMIN'">save</button>
</div>

修改heroes.component.spec.ts,在TestBed.configureTestingModule的declarations中添加HasRoleDirective。否則執行ng test時會報如下錯誤:

HeroesComponent should be created FAILED
Can't bind to 'appHasRole' since it isn't a known property of 'div'

JWT集成完畢,來測試一下吧!
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

單元測試與集成測試

使用Angular CLI創建的App已配置好測試環境,生成了測試配置文件和樣例代碼。默認,Angular單元測試使用Jasmine測試框架和Karma測試運行器,集成測試使用Jasmine測試框架和Protractor end-to-end 測試框架。

單元測試
單元測試配置文件karma.conf.js 和 test.ts,默認測試文件擴展名爲.spec.ts,使用Chrome瀏覽器。在使用CLI創建component、service等時會自動創建測試文件。執行CLI命令 ng test 即可運行單元測試:

ng test

在控制檯會輸出測試結果,還會打開瀏覽器:
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
瀏覽器會顯示測試結果,總測試數,失敗數。在頂部,每個點或叉對應一個測試用例,點表示成功,叉表示失敗,鼠標移到點或叉上會顯示測試信息。點擊測試結果中的某一行,可重新運行某個或某組(測試套件)測試。代碼修改後會重新運行測試。

運行單元測試時可生成代碼覆蓋率報告,報告保存在項目根目錄下的coverage文件夾內:

ng test --watch=false --code-coverage

如想每次測試都生成報告,可修改CLI配置文件angular.json:

"test": {
  "options": {
    "codeCoverage": true
  }
}

可設定測試覆蓋率指標,編輯配置文件karma.conf.js,增加如下內容:

coverageIstanbulReporter: {
  reports: [ 'html', 'lcovonly' ],
  fixWebpackSourcePaths: true,
  thresholds: {
    statements: 80,
    lines: 80,
    branches: 80,
    functions: 80
  }
}

測試報告中達到標準的背景爲綠色。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

集成測試
使用CLI創建App會生成一個e2e項目,其中包含集成測試配置protractor.conf.js及測試代碼。測試文件擴展名必須爲.e2e-spec.ts,默認使用Chrome瀏覽器。執行CLI命令 ng e2e即可運行集成測試:

ng e2e

CI集成

在CI環境中運行測試不必使用瀏覽器界面,因此需修改瀏覽器配置,啓用no-sandbox(headless)模式。
karma.conf.js增加如下配置:

browsers: ['Chrome'],
customLaunchers: {
  ChromeHeadlessCI: {
    base: 'ChromeHeadless',
    flags: ['--no-sandbox']
  }
},

在e2e根目錄下創建一名爲protractor-ci.conf.js的新文件,內容如下:

const config = require('./protractor.conf').config;

config.capabilities = {
  browserName: 'chrome',
  chromeOptions: {
    args: ['--headless', '--no-sandbox']
  }
};

exports.config = config;

注意: windows系統要增加參數--disable-gpu

使用以下命令運行測試:

ng test --watch=false --progress=false --browsers=ChromeHeadlessCI
ng e2e --protractor-config=e2e\protractor-ci.conf.js

生成的覆蓋率報告文件夾中有一文件lcov.info可與Sonar集成,在Sonar管理界面配置LCOV Files路徑,即可在Sonar中查看測試情況。
Angular 6集成Spring Boot 2,Spring Security,JWT和CORS
與Jenkins集成同樣使用Jenkinsfile,示例如下:

node {
    checkout scm
    stage('install') {
      bat 'npm install'
    }
    stage('test') {
      bat 'ng test --watch=false --progress=false --code-coverage --browsers=ChromeHeadlessCI'
      bat 'ng e2e --protractor-config=e2e\protractor-ci.conf.js'
    }
    stage('sonar-scanner') {
      bat 'sonar-scanner -Dsonar.projectKey=heroes-web -Dsonar.sources=src -Dsonar.typescript.lcov.reportPaths=coverage\lcov.info 
            -Dsonar.host.url=http://127.0.0.1:9000/sonar -Dsonar.login=1596abae7b68927b1cecd276d1b5149e86375cb2'
    }
    stage('build') {
      bat 'ng build --prod --base-href=/heroes/'
    }
}

說明:

  1. Sonar需安裝SonarTS插件
  2. Jenkins服務器端需安裝Node.js、Angular CLI、sonar-scanner和Chrome。

部署

後臺執行mvn clean package後,將heroes-api-1.0.0.jar拷貝到目標機器,然後執行:

java -jar heroes-api-1.0.0.jar

前臺執行以下命令編譯:

ng build --prod

將編譯好的dist目錄下的文件拷貝到Apache Server的html目錄下即可。如果部署在服務器的子目錄下,需設置--base-href(如index.html位於/my/app/目錄下):

ng build --prod --base-href=/my/app/

這是最簡易的部署方式,更進一步您可以使用docker。

附錄

如何配置審計日誌

增加一個appender,配置一個單獨的日誌文件;再增加一個logger,注意要配置additivity="false",這樣寫audit日誌時不會寫到其他層次的日誌中。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="dev">
        <property name="LOG_FILE" value="heroes.log"/>
        <property name="AUDIT_FILE" value="audit.log"/>
    </springProfile>
    <springProfile name="prod">
        <property name="LOG_FILE" value="/var/log/heroes.log"/>
        <property name="AUDIT_FILE" value="/var/log/audit.log"/>
    </springProfile>

    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <logger name="root" level="WARN"/>

    <appender name="AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %5p --- %m%n</pattern>
        </encoder>
        <file>${AUDIT_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>${AUDIT_FILE}.%i</fileNamePattern>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <MaxFileSize>10MB</MaxFileSize>
        </triggeringPolicy>
    </appender>

    <logger name="audit" level="info" additivity="false">
        <appender-ref ref="AUDIT"/>
    </logger>

    <springProfile name="dev">
        <logger name="root" level="INFO"/>
    </springProfile>
    <springProfile name="prod">
        <logger name="root" level="INFO"/>
    </springProfile>
</configuration>

調用:

private static final Logger logger = LoggerFactory.getLogger("audit");

自動重啓

開發Angular時,運行ng serve,代碼改變後會自動重新編譯。Spring Boot有這樣的功能麼?可以增加spring-boot-devtools實現:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <optional>true</optional>
</dependency>

Angular 6集成Spring Boot 2,Spring Security,JWT和CORS

參考文檔

Angular
Spring Boot
Spring Security
JWT Libraries
JSON Web Tokens (JWT) in Auth0
Springfox Swagger
Postman
Angular Security - Authentication With JSON Web Tokens (JWT): The Complete Guide
Integrating Angular 2 with Spring Boot, JWT, and CORS, Part 1
Integrating Angular 2 with Spring Boot, JWT, and CORS, Part 2
使用SpringBoot開啓微服務之旅
Spring MVC @RequestMapping Annotation Example with Controller, Methods, Headers, Params, @RequestParam, @PathVariable
The logback manual
測試框架-Jasmine
Version 6 of Angular Now Available
Lombok 介紹
Project Lombok

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章