主要内容:Spring Boot 2基础知识、异常处理、测试、CORS配置、Actuator监控,集成springfox-swagger生成JSON API文档;利用Swagger UI、Postman进行Rest API测试;Angular基础知识、国际化、测试、NZ-ZORRO;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 。
说明:最新代码使用Keycloak进行认证与授权,删除了原JWT、用户、权限、登录等相关代码,本文档代码保存在jwt-1.0.0 branch。
技术堆栈
- Spring Boot 2.2.0.RELEASE
- Spring Security
- Spring Data
- Spring Actuator
- JWT
- Springfox Swagger 2.9.2
- Angular 8.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、PostgreSQL、Lombok)后,点击Generate the 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.2.0.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.8.3</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> <optional>true</optional> </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为例,dev和prod profile使用了不同的配置文件:
application.yml
spring: profiles: active: @project.profile@ banner: charset: utf-8 image: location: classpath:banner.jpg location: classpath:banner.txt messages: encoding: UTF-8 basename: messages resources: add-mappings: true 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 springfox: documentation: swagger: v2: path: /api-docs api: base-path: /api security: cors: allowed-origins: "*" allowed-methods: GET,POST,DELETE,PUT,OPTIONS allowed-headers: Accept,Accept-Encoding,Accept-Language,Authorization,Connection,Content-Type,Host,Origin,Referer,User-Agent,X-Requested-With jwt: header: Authorization secret: mySecret expiration: 7200 issuer: ITRunner authentication-path: /api/auth
application-dev.yml
spring: jpa: hibernate: ddl-auto: create-drop properties: hibernate: format_sql: true show-sql: true datasource: platform: h2 initialization-mode: always server: port: 8080 security: cors: allowed-origins: "*"
application-prod.yml
spring: 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: mypassword initialization-mode: never server: port: 8000 security: cors: allowed-origins: itrunner.org
配置文件中包含了Banner、Swagger、CORS、JWT、Actuator、Profile等内容,其中active profile使用@project.profile@与pom属性建立了关联,这些将在后面的演示中用到。
下面是用来读取自定义配置的类SecurityProperties:
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(prefix = "security") public class SecurityProperties { 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="heroes.log"/> <property name="LOG_FILE_MAX_HISTORY" value="2"/> </springProfile> <springProfile name="prod"> <property name="LOG_FILE" value="/var/log/heroes.log"/> <property name="LOG_FILE_MAX_HISTORY" value="30"/> </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>
国际化
在配置文件中,可以定义国际化资源文件位置、编码,默认分别为messages、UTF-8:
messages: encoding: UTF-8 basename: messages
messages.properties
hero.notFound=Could not find hero with id {0}
Messages Component
package org.itrunner.heroes.util; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Component; import javax.annotation.Resource; @Component public class Messages { @Resource private MessageSource messageSource; public String getMessage(String code) { return getMessage(code, null); } public String getMessage(String code, Object[] objects) { return messageSource.getMessage(code, objects, LocaleContextHolder.getLocale()); } }
初始化数据
配置中可定义Spring Boot启动时是否初始化数据:
datasource: initialization-mode: always
在resources下创建data.sql文件,内容如下:
INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Dr Nice', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Narco', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Bombasto', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Celeritas', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Magneta', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'RubberMan', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Dynama', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Dr IQ', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Magma', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO HERO(ID, HERO_NAME, CREATE_BY, CREATE_TIME) VALUES(NEXTVAL('HERO_SEQ'), 'Tornado', 'admin', to_date('01-07-2019', 'dd-MM-yyyy')); INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'admin', '$2a$08$lDnHPz7eUkSi6ao14Twuau08mzhWrL4kyZGGU5xfiGALO/Vxd5DOi', 'admin@itrunner.org', TRUE); INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'jason', '$2a$10$6m2VoqZAxa.HJNErs2lZyOFde92PzjPqc88WL2QXYT3IXqZmYMk8i', 'jason@itrunner.org', TRUE); INSERT INTO USERS(ID, USERNAME, PASSWORD, EMAIL, ENABLED) VALUES (NEXTVAL('USER_SEQ'), 'coco', '$2a$10$TBPPC.JbSjH1tuauM8yRauF2k09biw8mUDmYHMREbNSXPWzwY81Ju', 'coco@itrunner.org', FALSE); 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);
说明:
- 不同数据库语法不同时,需分别创建初始化文件,命名格式data-${platform}.sql,比如data-h2.sql、data-postgresql.sql
- 密码与用户名相同
Domain
在"Tour of Heroes"中使用了angular-in-memory-web-api,此处使用H2嵌入式数据库取代,增加Hero Domain。
Hero Domain
package org.itrunner.heroes.domain; import lombok.Data; import lombok.NoArgsConstructor; import javax.persistence.*; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; import java.util.Date; @Entity @Data @NoArgsConstructor @Table(name = "HERO", uniqueConstraints = {@UniqueConstraint(name = "UK_HERO_NAME", columnNames = {"HERO_NAME"})}) 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; @NotNull @Size(min = 3, max = 30) @Column(name = "HERO_NAME", length = 30, nullable = false) private String name; @Column(name = "CREATE_BY", length = 50, updatable = false, nullable = false) private String createBy; @Column(name = "CREATE_TIME", updatable = false, nullable = false) @Temporal(TemporalType.TIMESTAMP) private Date createTime; @Column(name = "LAST_MODIFIED_BY", length = 50) private String lastModifiedBy; @Column(name = "LAST_MODIFIED_TIME") @Temporal(TemporalType.TIMESTAMP) private Date lastModifiedTime; public Hero(Long id, String name) { this.id = id; this.name = 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.List; @Entity @Getter @Setter @Table(name = "USERS", uniqueConstraints = { @UniqueConstraint(name = "UK_USERS_USERNAME", columnNames = {"USERNAME"}), @UniqueConstraint(name = "UK_USERS_EMAIL", columnNames = {"EMAIL"})}) public class User { @Id @Column(name = "ID") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "USER_SEQ") @SequenceGenerator(name = "USER_SEQ", sequenceName = "USER_SEQ", allocationSize = 1) private Long id; @Column(name = "USERNAME", length = 50, nullable = false) @NotNull @Size(min = 4, max = 50) private String username; @Column(name = "PASSWORD", length = 100, nullable = false) @NotNull @Size(min = 4, max = 100) private String password; @Column(name = "EMAIL", length = 50, nullable = false) @NotNull @Size(min = 4, max = 50) private String email; @Column(name = "ENABLED") @NotNull private Boolean enabled; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "USER_AUTHORITY", joinColumns = {@JoinColumn(name = "USER_ID", referencedColumnName = "ID", foreignKey = @ForeignKey(name = "FK_USER_ID"))}, inverseJoinColumns = {@JoinColumn(name = "AUTHORITY_ID", referencedColumnName = "ID", foreignKey = @ForeignKey(name = "FK_AUTHORITY_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", allocationSize = 1) private Long id; @Column(name = "AUTHORITY_NAME", length = 50, nullable = false) @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; import java.util.Optional; public interface UserRepository extends JpaRepository<User, Long> { Optional<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.itrunner.heroes.util.Messages; 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 { private final HeroRepository repository; private final Messages messages; @Autowired public HeroService(HeroRepository repository, Messages messages) { this.repository = repository; this.messages = messages; } public Hero getHeroById(Long id) { return repository.findById(id).orElseThrow(() -> new HeroNotFoundException(messages.getMessage("hero.notFound", new Object[]{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 Framework提供默认的HandlerExceptionResolver:DefaultHandlerExceptionResolver、ExceptionHandlerExceptionResolver、ResponseStatusExceptionResolver等,可查看全局异常处理方法DispatcherServlet.processHandlerException()了解处理过程。最终,BasicErrorController的error(HttpServletRequest request)方法返回ResponseEntity:
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(String message) { super(message); } }
更通用的方法是使用@ControllerAdvice定义一个类统一处理Exception,如下:
RestResponseEntityExceptionHandler
package org.itrunner.heroes.exception; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import javax.persistence.EntityNotFoundException; import java.util.List; @ControllerAdvice(basePackages = {"org.itrunner.heroes.controller"}) public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler({ EntityNotFoundException.class, DuplicateKeyException.class, DataIntegrityViolationException.class, DataAccessException.class, Exception.class }) public final ResponseEntity<Object> handleAllException(Exception e) { logger.error(e.getMessage(), e); if (e instanceof EntityNotFoundException) { return notFound(getExceptionName(e), e.getMessage()); } if (e instanceof DuplicateKeyException) { return badRequest(getExceptionName(e), e.getMessage()); } if (e instanceof DataIntegrityViolationException) { return badRequest(getExceptionName(e), e.getMessage()); } if (e instanceof DataAccessException) { return badRequest(getExceptionName(e), e.getMessage()); } return badRequest(getExceptionName(e), e.getMessage()); } @Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { StringBuilder messages = new StringBuilder(); List<ObjectError> globalErrors = ex.getBindingResult().getGlobalErrors(); globalErrors.forEach(error -> messages.append(error.getDefaultMessage()).append(";")); List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors(); fieldErrors.forEach(error -> messages.append(error.getField()).append(" ").append(error.getDefaultMessage()).append(";")); ErrorMessage errorMessage = new ErrorMessage(getExceptionName(ex), messages.toString()); return badRequest(errorMessage); } @Override protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) { return new ResponseEntity<>(new ErrorMessage(getExceptionName(ex), ex.getMessage()), headers, status); } private ResponseEntity<Object> badRequest(ErrorMessage errorMessage) { return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST); } private ResponseEntity<Object> badRequest(String error, String message) { return badRequest(new ErrorMessage(error, message)); } private ResponseEntity<Object> notFound(String error, String message) { return new ResponseEntity(new ErrorMessage(error, message), HttpStatus.NOT_FOUND); } private String getExceptionName(Exception e) { return e.getClass().getSimpleName(); } }
ErrorMessage
package org.itrunner.heroes.exception; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Getter; import java.util.Date; @Getter @JsonInclude(JsonInclude.Include.NON_EMPTY) public class ErrorMessage { private Date timestamp; private String error; private String message; public ErrorMessage() { this.timestamp = new Date(); } public ErrorMessage(String error, String message) { this(); this.error = error; this.message = message; } }
再次测试,输出结果如下:
说明:
- ResponseEntityExceptionHandler对内部Spring MVC异常进行了处理,但未将错误信息写入Response Body中,可覆盖handleExceptionInternal方法自定义处理方式。另外,为了返回详细的校验错误信息,覆盖了handleMethodArgumentNotValid方法。
- @RestController内定义的ExceptionHandler优先级更高。
- 此处仅为示例,对错误信息应进行适当的处理,信息应清晰,不包含敏感数据
Bean Validation
在RestController验证请求参数,而不是推迟到Service层才验证,尽早验证可以避免执行不必要的过程,也可以简化代码。在前面的Entity中我们添加了Validation注解,怎么在REST中启用验证呢,仅需给方法参数添加@Valid注解,如下:
@PostMapping("/heroes") public Hero addHero(@Valid @RequestBody Hero hero) { return service.saveHero(hero); } @PutMapping("/heroes") public Hero updateHero(@Valid @RequestBody Hero hero) { return service.saveHero(hero); }
上一节,我们重写了handleMethodArgumentNotValid方法,如保存或更新Hero时未输入name,则会显示如下信息:
说明:实际项目中通常使用DTO,在DTO添加Validation注解,而不是在Entity。
WebSecurityConfig和CORS
出于安全原因,浏览器限制从脚本内发起跨源(域或端口)的HTTP请求,这意味着Web应用程序只能从加载应用程序的同一个域请求HTTP资源。CORS(Cross-Origin Resource Sharing)机制允许Web应用服务器进行跨域访问控制,从而使跨域数据传输得以安全进行。
CORS
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.SecurityProperties.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 SecurityProperties securityProperties; @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 = securityProperties.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.itrunner.heroes.exception.ErrorMessage; 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.context.SpringBootTest.WebEnvironment; 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 = WebEnvironment.RANDOM_PORT) public class HeroesApplicationTests { @Autowired private TestRestTemplate restTemplate; @Test public void crudSuccess() { 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(5); // 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); } @Test public void addHeroValidationFailed() { Hero hero = new Hero(); ResponseEntity<ErrorMessage> responseEntity = restTemplate.postForEntity("/api/heroes", hero, ErrorMessage.class); assertThat(responseEntity.getStatusCodeValue()).isEqualTo(400); assertThat(responseEntity.getBody().getError()).isEqualTo("MethodArgumentNotValidException"); } }
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.6.0.1398</version> </plugin> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.4</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。
验证流程
浏览器收到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 lombok.extern.slf4j.Slf4j; import org.itrunner.heroes.config.SecurityProperties; 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 @Slf4j public class JwtTokenUtil { private static final String CLAIM_AUTHORITIES = "authorities"; private final SecurityProperties.Jwt jwtProperties; @Autowired public JwtTokenUtil(SecurityProperties securityProperties) { this.jwtProperties = securityProperties.getJwt(); } public String generate(UserDetails user) { try { Algorithm algorithm = Algorithm.HMAC256(jwtProperties.getSecret()); return JWT.create() .withIssuer(jwtProperties.getIssuer()) .withIssuedAt(new Date()) .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getExpiration() * 1000)) .withSubject(user.getUsername()) .withArrayClaim(CLAIM_AUTHORITIES, AuthorityUtil.getAuthorities(user)) .sign(algorithm); } catch (IllegalArgumentException e) { return null; } } public UserDetails verify(String token) { if (token == null) { return null; } try { Algorithm algorithm = Algorithm.HMAC256(jwtProperties.getSecret()); JWTVerifier verifier = JWT.require(algorithm).withIssuer(jwtProperties.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.getMessage(), 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::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 { private final UserRepository userRepository; @Autowired public UserDetailsServiceImpl(UserRepository userRepository) { this.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 SecurityProperties securityProperties; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authToken = request.getHeader(securityProperties.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.SecurityProperties.Cors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; 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.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; private final JwtAuthenticationEntryPoint unauthorizedHandler; private final SecurityProperties securityProperties; private final UserDetailsService userDetailsService; @Autowired public WebSecurityConfig(JwtAuthenticationEntryPoint unauthorizedHandler, SecurityProperties securityProperties, @Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService) { this.unauthorizedHandler = unauthorizedHandler; this.securityProperties = securityProperties; this.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(securityProperties.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 = securityProperties.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。
package org.itrunner.heroes.controller; import lombok.extern.slf4j.Slf4j; import org.itrunner.heroes.util.JwtTokenUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; 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.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping(value = "${api.base-path}", produces = MediaType.APPLICATION_JSON_VALUE) @Slf4j public class AuthenticationController { private final AuthenticationManager authenticationManager; private final JwtTokenUtil jwtTokenUtil; private final UserDetailsService userDetailsService; @Autowired public AuthenticationController(AuthenticationManager authenticationManager, JwtTokenUtil jwtTokenUtil, @Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService) { this.authenticationManager = authenticationManager; this.jwtTokenUtil = jwtTokenUtil; this.userDetailsService = userDetailsService; } @PostMapping("/auth") public AuthenticationResponse login(@RequestBody AuthenticationRequest request) { // Perform the security Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); SecurityContextHolder.getContext().setAuthentication(authentication); // Reload user details so we can generate token UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername()); String token = jwtTokenUtil.generate(userDetails); // Return the token return new AuthenticationResponse(token); } @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public void handleAuthenticationException(AuthenticationException exception) { log.error(exception.getMessage(), exception); } }
AuthenticationRequest
package org.itrunner.heroes.controller; import lombok.Getter; import lombok.Setter; @Getter @Setter public class AuthenticationRequest { private String username; private String password; }
AuthenticationResponse
package org.itrunner.heroes.controller; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class AuthenticationResponse { private String token; }
重启Spring Boot,用postman来测试一下,输入验证URL:localhost:8080/auth、正确的用户名和密码,提交后会输出token。
此时如请求localhost:8080/api/heroes会输出403错误,将token填到Authorization header中,则可查询出hero。
用户"admin"可以执行CRUD操作,"jason"只有查询权限。
集成测试
启用用户验证后,执行集成测试前要先登录获取token,然后添加token到request header中,增加如下代码:
@Before public void setup() { AuthenticationRequest authenticationRequest = new AuthenticationRequest(); authenticationRequest.setUsername("admin"); authenticationRequest.setPassword("admin"); String token = restTemplate.postForObject("/api/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); })); }
JPA Auditing
常有这样的需求,保存、更新数据库时记录创建人、创建时间、修改人、修改时间,如手工更新这样的字段比较烦琐,Spring JPA的Auditing功能满足了这一需求。
使用方法:
- 在字段上添加注解@CreatedBy、@CreatedDate、@LastModifiedBy、@LastModifiedDate
@Column(name = "CREATE_BY", length = 50, updatable = false, nullable = false) @CreatedBy private String createBy; @Column(name = "CREATE_TIME", updatable = false, nullable = false) @Temporal(TemporalType.TIMESTAMP) @CreatedDate private Date createTime; @Column(name = "LAST_MODIFIED_BY", length = 50) @LastModifiedBy private String lastModifiedBy; @Column(name = "LAST_MODIFIED_TIME") @Temporal(TemporalType.TIMESTAMP) @LastModifiedDate private Date lastModifiedTime;
- 在Entity上添加注解@EntityListeners(AuditingEntityListener.class)
@Entity @EntityListeners(AuditingEntityListener.class) @Table(name = "HERO", uniqueConstraints = {@UniqueConstraint(name = "UK_HERO_NAME", columnNames = {"HERO_NAME"})}) public class Hero { ... }
- 在SpringBootApplication类上添加注解@EnableJpaAuditing
@SpringBootApplication @EnableJpaAuditing public class HeroesApplication { ... }
- 实现AuditorAware获取当前用户
package org.itrunner.heroes.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; import org.springframework.security.core.context.SecurityContextHolder; import java.util.Optional; @Configuration public class SpringSecurityAuditorAware implements AuditorAware<String> { @Override public Optional<String> getCurrentAuditor() { return Optional.ofNullable((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()); } }
集成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", "sjc-925@163.com")) .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
前面的代码未针对API Doc进行配置,文档会自动生成。为了文档更详细,可读性更好,需添加Swagger Annotation,如下:
package org.itrunner.heroes.controller; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; import lombok.extern.slf4j.Slf4j; import org.itrunner.heroes.domain.Hero; import org.itrunner.heroes.service.HeroService; 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 javax.validation.Valid; import java.util.HashMap; import java.util.List; import java.util.Map; @RestController @RequestMapping(value = "${api.base-path}", produces = MediaType.APPLICATION_JSON_VALUE) @Api(tags = {"Hero Controller"}) @Slf4j public class HeroController { private final HeroService service; @Autowired public HeroController(HeroService service) { this.service = service; } @ApiOperation("Get hero by id") @GetMapping("/heroes/{id}") public Hero getHeroById(@ApiParam(required = true, example = "1") @PathVariable("id") Long id) { return service.getHeroById(id); } @ApiOperation("Get all heroes") @GetMapping("/heroes") public List<Hero> getHeroes() { return service.getAllHeroes(); } @ApiOperation("Search heroes by name") @GetMapping("/heroes/") public List<Hero> searchHeroes(@ApiParam(required = true) @RequestParam("name") String name) { return service.findHeroesByName(name); } @ApiOperation("Add new hero") @PostMapping("/heroes") public Hero addHero(@ApiParam(required = true) @Valid @RequestBody Hero hero) { return service.saveHero(hero); } @ApiOperation("Update hero info") @PutMapping("/heroes") public Hero updateHero(@ApiParam(required = true) @Valid @RequestBody Hero hero) { return service.saveHero(hero); } @ApiOperation("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; import lombok.Getter; import lombok.Setter; @Getter @Setter public class AuthenticationRequest { @ApiModelProperty(value = "username", example = "admin", required = true) private String username; @ApiModelProperty(value = "password", example = "admin", required = true) private String password; }
Swagger UI测试
使用Swagger UI测试有以下优点:
- 可直接点选要测试的API
- 提供需要的参数和默认值,只需编辑参数值
- 只需一次认证
- 直观的显示Request和Response信息
先测试auth api来获取token,点击Try it out,然后输入username和password,点击Excute,成功后会输出token。
下一步进行验证,点击页面上方的Authorize,输入token,验证后就可以进行其他测试了。
Angular
配置开发环境
- 安装Node.js10.9.0或以上版本
- 检查npm版本
npm -v
可以更新到最新版:
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' // 修改为生产域名 };
- 修改hero.service.ts的heroesUrl,将“api/heroes”替换为"
${environment.apiUrl}/api/heroes
" :
import {environment} from '../environments/environment'; ... private heroesUrl = `${environment.apiUrl}/api/heroes`;
- 修改hero.service.ts错误处理方法
当发生错误时显示REST的错误消息,未传入result参数时,返回空Observable(原代码存在问题,比如当添加重名的hero时,因hero entity设定name为unique,页面会添加空行)。
private handleError<T>(operation = 'operation', result?: T) { return (errorResponse: any): Observable<T> => { console.error(errorResponse.error); // log to console instead this.log(`${operation} failed: ${errorResponse.error.message}`); // Let the app keep running by returning an empty result. if (result) { return of(result as T); } return of(); }; }
当添加重复记录时,显示如下信息:
- 安装、启动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/api/auth 验证用户,如验证成功则解析token,在localStorage中保存token。
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'; import {throwError} from 'rxjs/internal/observable/throwError'; const httpOptions = { headers: new HttpHeaders({'Content-Type': 'application/json'}) }; @Injectable({providedIn: 'root'}) export class AuthenticationService { constructor(private http: HttpClient) { } login(name: string, pass: string): Observable<boolean> { return this.http.post<any>(`${environment.apiUrl}/api/auth`, JSON.stringify({username: name, password: pass}), 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: name, token: response.token, tokenParsed: this.decodeToken(response.token)})); 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[] = this.getAuthorities(currentUser.tokenParsed); return authorities.indexOf('ROLE_' + role) !== -1; } decodeToken(token: string): string { let payload: string = token.split('.')[1]; payload = payload.replace('/-/g', '+').replace('/_/g', '/'); switch (payload.length % 4) { case 0: break; case 2: payload += '=='; break; case 3: payload += '='; break; default: throwError('Invalid token'); } payload = (payload + '===').slice(0, payload.length + (payload.length % 4)); return decodeURIComponent(escape(atob(payload))); } getAuthorities(tokenParsed: string): string[] { return JSON.parse(tokenParsed).authorities; } }
创建登录页面
我们使用ng-zorro来创建登录页面,先引入ng-zorro-antd:
ng add ng-zorro-antd --theme Installed packages for tooling via npm. ? Add icon assets [ Detail: https://ng.ant.design/components/icon/en ] Yes ? Choose your locale code: en_US ? Choose template to create project: blank
在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
ng-zorro-antd 8对Form表单进行了全面增强,新版本只需传入错误提示内容即可工作。
<h2>Login</h2> <form nz-form #loginForm="ngForm" class="login-form"> <nz-form-item> <nz-form-control nzErrorTip="Please input your username!"> <nz-input-group nzPrefixIcon="user"> <input type="text" nz-input [(ngModel)]="model.username" id="username" name="userName" placeholder="Username" required/> </nz-input-group> </nz-form-control> </nz-form-item> <nz-form-item> <nz-form-control nzErrorTip="Please input your Password!"> <nz-input-group nzPrefixIcon="lock"> <input type="password" nz-input [(ngModel)]="model.password" id="password" name="password" placeholder="Password" required/> </nz-input-group> </nz-form-control> </nz-form-item> <nz-form-item> <nz-form-text>(Username: admin, Password: admin)</nz-form-text> </nz-form-item> <nz-form-item> <nz-form-control> <button nz-button class="login-form-button" [nzType]="'primary'" [disabled]="loginForm.invalid" (click)="login()">Login</button> </nz-form-control> </nz-form-item> </form>
login.component.css
.login-form { max-width: 300px; } .login-form-forgot { float: right; } .login-form-button { width: 100%; }
在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
访问需认证的REST服务时,需要在HTTP Header中添加Bearer Token,有两种添加方式:
- 在http请求中添加httpOptions
const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json'}), 'Authorization': 'Bearer ' + this.authenticationService.getToken() };
- 使用HttpInterceptor拦截所有http请求添加token
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需要在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(); } } }
注意,要在AppModule的declarations中声明HasRoleDirective。
接下来修改heroes.component.html和hero-detail.component.html,只有"ADMIN"用户才有新增、修改、删除权限:
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组件含有测试脚本heroes.component.spec.ts,需在TestBed.configureTestingModule的declarations中添加HasRoleDirective。
JWT集成完毕,来测试一下吧!
国际化
Angular国际化主要有两个方面:
本地格式显示日期、数字、百分比和货币
管道DatePipe、DecimalPipe、PercentPipe和CurrencyPipe根据 LOCALE_ID来格式化数据。默认Angular只包含en-US的本地化数据,可以在angular.json的“configurations”中指定i18nLocale:
"configurations": { ... "zh": { ... "i18nLocale": "zh" ... } }
使用ng serve、ng build的--configuration参数时,CLI 会自动导入相应的本地化数据。
也可以在app.module.ts中注册:
import {registerLocaleData} from '@angular/common'; import zh from '@angular/common/locales/zh'; registerLocaleData(zh);
组件模板中文本的国际化
- 使用 i18n 属性标记要国际化的文本
以登录页面为为例:
<h2 i18n="@@login">Login</h2> <form nz-form #loginForm="ngForm" class="login-form"> <nz-form-item> <nz-form-control i18n-nzErrorTip="@@usernameErrorTip" nzErrorTip="Please input your username!"> <nz-input-group nzPrefixIcon="user"> <input type="text" nz-input [(ngModel)]="model.username" id="username" name="userName" i18n-placeholder="@@usernamePlaceholder" placeholder="Username" required/> </nz-input-group> </nz-form-control> </nz-form-item> <nz-form-item> <nz-form-control i18n-nzErrorTip="@@passwordErrorTip" nzErrorTip="Please input your Password!"> <nz-input-group nzPrefixIcon="lock"> <input type="password" nz-input [(ngModel)]="model.password" id="password" name="password" i18n-placeholder="@@passwordPlaceholder" placeholder="Password" required/> </nz-input-group> </nz-form-control> </nz-form-item> <nz-form-item> <nz-form-text i18n="@@loginTip">(Username: admin, Password: admin)</nz-form-text> </nz-form-item> <nz-form-item> <nz-form-control> <button nz-button class="login-form-button" [nzType]="'primary'" [disabled]="loginForm.invalid" (click)="login()" i18n="@@login">Login</button> </nz-form-control> </nz-form-item> </form>
说明:
- Angular的 i18n 提取工具会为模板中每个带有 i18n 属性的元素生成一个翻译单元(translation unit)条目,并保存到一个文件中。默认,为每个翻译单元指定一个唯一的 id:
<trans-unit id="ba0cc104d3d69bf669f97b8d96a4c5d8d9559aa3" datatype="html">
使用@@可以自定义id,这样避免了重新提取时id的变化,相同文本也可以共用一个translation unit,让维护变得更简单。
- 要把一个属性标记为需要国际化的,使用一个形如i18n-x的属性,其中的 x 是要国际化的属性的名字
- 要翻译一段纯文本,又不希望创建一个新的 DOM 元素,可以把这段文本包裹进一个ng-container元素中
<h2>{{hero.name | uppercase}} <ng-container i18n="@@detail">Details</ng-container></h2>
- 使用Angular CLI的xi18n命令创建翻译文件
ng xi18n --output-path src/locale
xi18n支持三种文件格式xlf (XLIFF 1.2,默认)、xlf2(XLIFF 2)和xmb,可以使用 --i18nFormat 选项指定:
ng xi18n --i18n-format=xlf
文件名默认为messages.xlf,可以使用--out-file指定:
ng xi18n --out-file source.xlf
- 翻译源文本
复制messages.xlf文件,命名为messages.zh.xlf,放到locale目录下,文件内容如下:
<?xml version="1.0" encoding="UTF-8" ?> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <file source-language="en" datatype="plaintext" original="ng2.template"> <body> <trans-unit id="login" datatype="html"> <source>Login</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/login/login.component.html</context> <context context-type="linenumber">1</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/login/login.component.html</context> <context context-type="linenumber">23</context> </context-group> </trans-unit> ... </body> </file> </xliff>
在每个source标记下创建target标记,其中填写翻译后的内容:
<source>Login</source> <target>登录</target>
- 合并已经翻译的文件
在angular.json文件中配置"i18n"信息:
"build": { ... "configurations": { ... "production-zh": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "optimization": true, "outputHashing": "all", "sourceMap": false, "extractCss": true, "namedChunks": false, "aot": true, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, "outputPath": "dist/zh/", "baseHref": "/zh/", "i18nFile": "src/locale/messages.zh.xlf", "i18nFormat": "xlf", "i18nLocale": "zh", "i18nMissingTranslation": "error" }, "zh": { "aot": true, "i18nFile": "src/locale/messages.zh.xlf", "i18nFormat": "xlf", "i18nLocale": "zh", "i18nMissingTranslation": "error" } } }, "serve": { ... "configurations": { ... "zh": { "browserTarget": "angular.io-example:build:zh" } } }
开发、编译时执行如下命令:
ng serve --configuration=zh ng build --configuration=production-zh
多语言环境部署与切换
本例支持中、英两种语言,编译后目录分别为en、zh。为同时支持两种语言,需将两者都部署到服务器中,比如Apache,切换语言仅需访问不同的目录。
实现语言切换功能,修改AppComponent如下:
app.component.html:
<div nz-row> <div nz-col nzSpan="6"><h1>{{title}}</h1></div> <div nz-col nzSpan="2">{{currentDate | date}}</div> <div nz-col nzSpan="4"> <nz-radio-group [(ngModel)]="selectedLanguage" (ngModelChange)="switchLanguage()" [nzButtonStyle]="'solid'"> <label nz-radio-button [nzValue]="language.code" *ngFor="let language of supportLanguages">{{ language.label }}</label> </nz-radio-group> </div> </div> <nav> <a routerLink="/login" i18n="@@login">Login</a> <a routerLink="/dashboard" i18n="@@dashboard">Dashboard</a> <a routerLink="/heroes" i18n="@@heroes">Heroes</a> </nav> <router-outlet></router-outlet> <app-messages></app-messages>
app.component.ts:
import {Component, Inject, LOCALE_ID} from '@angular/core'; import {en_US, NzI18nService, zh_CN} from 'ng-zorro-antd'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { title = 'Tour of Heroes'; selectedLanguage: string; currentDate: Date = new Date(); supportLanguages = [ {code: 'en', label: 'English'}, {code: 'zh', label: '中文'} ]; constructor(@Inject(LOCALE_ID) private localeId: string, private i18n: NzI18nService) { if (localeId === 'en-US') { this.selectedLanguage = 'en'; this.i18n.setLocale(en_US); } else { this.selectedLanguage = 'zh'; this.i18n.setLocale(zh_CN); } } switchLanguage() { window.location.href = `/${this.selectedLanguage}`; } }
中文界面:
单元测试
单元测试使用Jasmine测试框架和Karma测试运行器。
单元测试的配置文件有karma.conf.js和test.ts,默认,测试文件扩展名为.spec.ts,使用Chrome浏览器进行测试。使用CLI创建component、service等时会自动创建测试文件。
运行单元测试:
ng test
在控制台和浏览器会输出测试结果:
浏览器显示总测试数、失败数,在顶部,每个点或叉对应一个测试用例,点表示成功,叉表示失败,鼠标移到点或叉上会显示测试信息。点击测试结果中的某一行,可重新运行某个或某组(测试套件)测试。代码修改后会重新运行测试。
运行单元测试时可生成代码覆盖率报告,报告保存在项目根目录下的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 } }
测试报告中达到标准的背景为绿色:
集成测试
集成测试使用Jasmine测试框架和Protractor end-to-end测试框架。
项目根目录e2e文件夹,其中包含集成测试配置protractor.conf.js和测试代码。测试文件扩展名必须为.e2e-spec.ts,默认使用Chrome浏览器。
修改app.e2e-spec.ts,添加login测试,完整代码请查看github,部分内容如下:
... const targetHero = {id: 5, name: 'Magneta'}; ... function getPageElts() { const navElements = element.all(by.css('app-root nav a')); return { navElts: navElements, appLoginHref: navElements.get(0), appLogin: element(by.css('app-root app-login')), loginTitle: element(by.css('app-root app-login > h2')), appDashboardHref: navElements.get(1), appDashboard: element(by.css('app-root app-dashboard')), topHeroes: element.all(by.css('app-root app-dashboard > div h4')), appHeroesHref: navElements.get(2), appHeroes: element(by.css('app-root app-heroes')), allHeroes: element.all(by.css('app-root app-heroes li')), selectedHeroSubview: element(by.css('app-root app-heroes > div:last-child')), heroDetail: element(by.css('app-root app-hero-detail > div')), searchBox: element(by.css('#search-box')), searchResults: element.all(by.css('.search-result li')) }; } ... describe('Login tests', () => { beforeAll(() => browser.get('')); it('Title should be Login', () => { const page = getPageElts(); expect(page.loginTitle.getText()).toEqual('Login'); }); it('can login', () => { element(by.css('#username')).sendKeys('admin'); element(by.css('#password')).sendKeys('admin'); element(by.buttonText('Login')).click(); }); it('has dashboard as the active view', () => { const page = getPageElts(); expect(page.appDashboard.isPresent()).toBeTruthy(); }); });
运行集成测试:
ng e2e
测试结果:
Tutorial part 6 Initial page √ has title 'Tour of Heroes' √ has h1 'Tour of Heroes' √ has views Login,Dashboard,Heroes √ has login as the active view Login tests √ Title should be Login √ can login √ has dashboard as the active view Dashboard tests √ has top heroes √ selects and routes to Magneta details √ updates hero name (MagnetaX) in details view √ cancels and shows Magneta in Dashboard √ selects and routes to Magneta details √ updates hero name (MagnetaX) in details view √ saves and shows MagnetaX in Dashboard Heroes tests √ can switch to Heroes view √ can route to hero details √ shows MagnetaX in Heroes list √ deletes MagnetaX from Heroes list √ adds back Magneta √ displays correctly styled buttons Progressive hero search √ searches for 'Ma' √ continues search with 'g' √ continues search with 'e' and gets Magneta √ navigates to Magneta details view Executed 24 of 24 specs SUCCESS in 23 secs.
说明:
- 以上测试代码,后台启动后,仅第一次能成功运行。
- 浏览器驱动位于node_modules\protractor\node_modules\webdriver-manager\selenium目录下
- 为提高运行速度,不必每次运行测试都更新驱动
ng e2e --webdriver-update=false
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中查看测试情况:
与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/' } }
说明:
- Sonar需安装SonarTS插件
- 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 ng build --configuration=production-zh
如部署到Apache Server,将dist目录下的文件拷贝到Apache的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
Spring Boot REST – request validation
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
来源:51CTO
作者:川川Jason
链接:https://blog.51cto.com/7308310/2072364?source=drt