>>分享流行的Java框架以及开源软件,对孙卫琴的《精通Spring》、《Spring Cloud Alibaba微服务开发零基础入门到实操》提供技术支持 书籍支持  卫琴直播  品书摘要  在线测试  资源下载  联系我们
发表一个新主题 开启一个新投票 回复文章 您是本文章第 26876 个阅读者 刷新本主题
 * 贴子主题:  Angular 8集成Spring Boot 2详解-iTRunner 回复文章 点赞(0)  收藏  
作者:flybird    发表时间:2020-01-27 15:21:31     消息  查看  搜索  好友  邮件  复制  引用

  
主要内容: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
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
输入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);

  说明:
  1. 不同数据库语法不同时,需分别创建初始化文件,命名格式data-${platform}.sql,比如data-h2.sql、data-postgresql.sql
  2. 密码与用户名相同

Domain

在"Tour of Heroes"中使用了angular-in-memory-web-api,此处使用H2嵌入式数据库取代,增加Hero Domain。
Hero Domain    

package org.itrunner.heroes.domain;

import lombok.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中没有处理异常的代码,如数据操作失败会返回什么结果呢?例如,添加了重复的记录,会显示如下信息:
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
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);
    }
}

  点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小

     更通用的方法是使用@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;
    }
}

  再次测试,输出结果如下:
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
说明:
  1. ResponseEntityExceptionHandler对内部Spring MVC异常进行了处理,但未将错误信息写入Response Body中,可覆盖handleExceptionInternal方法自定义处理方式。另外,为了返回详细的校验错误信息,覆盖了handleMethodArgumentNotValid方法。
  2. @RestController内定义的ExceptionHandler优先级更高。
  3. 此处仅为示例,对错误信息应进行适当的处理,信息应清晰,不包含敏感数据

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,则会显示如下信息:
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
说明:实际项目中通常使用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结合使用。
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小

单元测试与集成测试

单元测试
使用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。

     验证流程
点击在新窗口中浏览原图
CTRL+鼠标滚轮放大或缩小
浏览器收到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,定义密码加密算法。    

[code] 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;

  

[这个贴子最后由 sunweiqin 在 2020-01-28 12:00:59 重新编辑]
  Java面向对象编程-->Java注解
  JavaWeb开发-->JSP技术详解(Ⅱ)
  JSP与Hibernate开发-->数据库事务的并发问题的解决方案
  Java网络编程-->基于UDP的数据报和套接字
  精通Spring-->通过Axios访问服务器
  Vue3开发-->Vue CLI脚手架工具
  面试官问什么是Spring循环依赖,该如何回答?
  微服务架构模型
  卫琴姐姐制作的最新视频课程:基于Spring的JavaWeb开发技术详...
  重新理解响应式编程
  Spring MVC的拦截器的详细用法
  拦截器不拦截静态资源的三种处理方法
  Spring配置中bean元素的id和name属性的区别
  Spring MVC中Controller的用法
  超详细使用Maven 搭建 Spring MVC 本地部署Tomcat 实现 Hell...
  Redis的漏洞利用,原来大佬都是这样操作的
  Spring Cloud构建微服务架构的分布式配置中心
  SpringCloud微服务框架搭建
  Spring Security中使用的责任链模式
  Spring Boot 整合 Ehcache
  什么是CXF
  更多...
 IPIP: 已设置保密
楼主      
该用户目前不在线 nihaota 
  
威望: 0
级别: 新手上路
魅力: 1315
经验: 1315
现金: 2944
发文章数: 243
注册时间: 0001-01-01
 消息  查看  搜索  好友  邮件  复制  引用


讨债公司
发文章时间 2022-10-28 20:25:15
 IPIP: 已设置保密 1 楼     
1页 1条记录 当前第1
发表一个新主题 开启一个新投票 回复文章


中文版权所有: JavaThinker技术网站 Copyright 2016-2026 沪ICP备16029593号-2
荟萃Java程序员智慧的结晶,分享交流Java前沿技术。  联系我们
如有技术文章涉及侵权,请与本站管理员联系。