本文涉及Spring Boot的基礎應用、CORS配置、Actuator監控、springfox-swagger集成、CI集成等,演示瞭如何利用Swagger生成JSON API文檔,如何利用Swagger UI和Postman進行Rest API測試。介紹了Angular 6的新特性,Angular 6與Spring Boot 2、Spring Security、JWT集成的方法。
本文主要參考了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.0.4.RELEASE
- Spring Security
- Spring Data
- Spring Actuator
- JWT
- Springfox Swagger2
- 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
輸入Group、Artifact,選擇Dependency(Web、JPA、Security、Actuator、H2、Lombok)後,點擊Generate Project,會生成zip包。下載後解壓,編輯POM文件,添加java-jwt和springfox-swagger。完成的POM文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.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.0.4.RELEASE</version>
<relativePath/>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<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.3.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</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:
allowedOrigins: "*"
allowedMethods: GET,POST,DELETE,PUT,OPTIONS
allowedHeaders: 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
show-sql: true
properties:
hibernate:
format_sql: true
datasource:
initialization-mode: always
server:
port: 8080
---
spring:
profiles: prod
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
datasource:
initialization-mode: always
server:
port: 8000
---
spring:
profiles:
active: dev
文件中包含了Banner、Swagger、CORS、JWT、Actuator、Profile等配置,這些將在後面的演示中用到。下面是用來讀取自定義配置的類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 (1, 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', '[email protected]', 1, PARSEDATETIME('01-01-2018', 'dd-MM-yyyy'));
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED, LASTPASSWORDRESETDATE) VALUES (2, 'jason', '$2a$10$6m2VoqZAxa.HJNErs2lZyOFde92PzjPqc88WL2QXYT3IXqZmYMk8i', '[email protected]', 1, PARSEDATETIME('01-01-2018','dd-MM-yyyy'));
INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED, LASTPASSWORDRESETDATE) VALUES (3, 'fisher', '$2a$10$TBPPC.JbSjH1tuauM8yRauF2k09biw8mUDmYHMREbNSXPWzwY81Ju', '[email protected]', 0, PARSEDATETIME('01-01-2018','dd-MM-yyyy'));
INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (1, 'ROLE_USER');
INSERT INTO AUTHORITY (ID, AUTHORITY_NAME) VALUES (2, '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);
說明:密碼與用戶名相同
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。如添加了重複的記錄,將顯示如下信息:
可查看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));
}
}
也可以使用@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
}
說明:@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結合使用。
單元測試與集成測試
單元測試
使用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並提交到SCM,則項目成員可編輯且Jenkins能自動同步。以下是簡單的Jenkinsfile示例:
node {
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。
驗證流程
瀏覽器收到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.io.UnsupportedEncodingException;
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 | UnsupportedEncodingException 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。
此時如再請求localhost:8080/api/heroes將會收到403錯誤,將token填入到Authorization header中,可以查詢出hero。
用戶"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", "http://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
訪問Swagger UI: http://localhost:8080/swagger-ui.html
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) @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) @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信息
先測試auth api來獲取token,點擊Try it out,然後輸入username和password,點擊Excute,成功後會輸出token。
下一步進行驗證,點擊頁面上方的Authorize,輸入token,驗證後就可以進行其他測試了。
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 libraryng 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
Material Dashboard
Material Data Table - 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。
- 刪除in-memory-data.service.ts,刪除app.module.ts中的InMemoryDataService、HttpClientInMemoryWebApiModule。package.json中的“angular-in-memory-web-api”也可刪除。
- 配置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
};
- 編輯hero.service.ts將“api/heroes”替換爲"
${environment.apiUrl}/api/heroes
" :
import {environment} from '../environments/environment';
...
private heroesUrl = `${environment.apiUrl}/api/heroes`;
- 修改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'))
);
}
- 顯示錯誤信息,修改如下:
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:
npm install
ng serve
測試:
此時訪問,頁面輸出以下錯誤:
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();
}
}
}
修改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>
JWT集成完畢,來測試一下吧!
部署
後臺執行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
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