#介绍
这个指南非常简单,不是从入门到进阶,不会面面俱到的讲如何从零起步,覆盖所有知识点。只是通过构建了一个小后端API工程,实现了基本功能。 这个指南中包含了Spring boot, Spring data, Spring Security的配置使用及单元测试的创建。
#Spring Boot简单配置
Spring Boot中提供了profile的概念,通过profile可以选择性的使用或者不适用应用程序的某些部分。
通过profile来选择使用一个class
- @Component
- @Profile("dev")
- public class LoggingEmailGateway implements EmailGateway {
- ...
- }
上面的代码中,@Profile注解标注了这个class的profile是“dev”,那么, 在application.properties
中使用spring.profiles.active=dev
或者在命令行中使用这个参数来激活这个profile: java -jar application.jar --spring.profiles.active=dev
某个profile专用的一组配置
默认情况下,spring boot是会把所有的配置放在application.properties中配置。如果需要为某个profile配置专用的一组配置内容,可以使用命名规范符合application-<profileName>.properties
的配置文件来实现:
application-prod.properties
server.port = 5000
配置日志
spring boot默认使用logback来记录日志,把日志内容输出到控制台上。
生产环境的日志配置
我们需要把生产环境的日志输出到文件中。这需要在配置文件中实现这个配置。在src/main/resources下面创建logback-spring.xml文件:
logback-spring.xml
- <?xml version="1.0" encoding="UTF-8"?>
- <configuration>
- <!-- spring boot默认的logback配置 -->
- <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
-
- <!-- dev和local这两个profile的配置,日志输出到控制台 -->
- <springProfile name="dev,local">
- <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
- <root level="INFO">
- <appender-ref ref="CONSOLE" />
- </root>
- </springProfile>
-
- <!-- staging和prod这两个profile的配置,日志输出到文件中-->
- <springProfile name="staging,prod">
- <include resource="org/springframework/boot/logging/logback/file-appender.xml"/>
- <root level="INFO">
- <appender-ref ref="FILE"/>
- </root>
- </springProfile>
- </configuration>
要指定日志文件名,需要修改application-staging.properties
和 application-prod.proerties
文件:
- logging.file=babyrabbit.log
- logging.level.root=INFO
测试环境的日志配置
要为测试环境配置日志,可以在src/test/resources目录下创建logback-test.xml。在运行测试时,这个文件会被放在classpath的前端,并被LogBack自动使用。 这个文件很简单,就是把调试日志输出到控制台:
- <configuration>
- <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
- <encoder>
- <pattern>%date{YYYY-MM-dd HH:mm:ss} %level [%thread] %logger{0} - %msg%n%ex</pattern>
- </encoder>
- </appender>
- <root level="WARN">
- <appender-ref ref="STDOUT"/>
- </root>
-
- <!-- 输出指定包的调试日志 -->
- <logger name="com.springbook.application">
- <level value="DEBUG"/>
- </logger>
- <logger name="org.hibernate">
- <level value="WARN"/>
- </logger>
- <logger name="org.hibernate.type">
- <level value="WARN"/> <!-- set to TRACE to view parameter binding in queries -->
- </logger>
- <logger name="org.springframework.security">
- <level value="WARN"/>
- </logger>
- </configuration>
在执行命令 mvn clean install
编译程序的时候,就会运行测试代码并在控制台输出调试日志。 如果不想在编译的时候看调试日志,可以让maven把所有的输出写入到一个文件里,这需要在pom.xml中配置surefire插件:
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-surefire-plugin</artifactId>
- <configuration>
- <redirectTestOutputToFile>true</redirectTestOutputToFile>
- <printSummary>false</printSummary>
- </configuration>
- </plugin>
这个插件将把每个测试的输出写入到一个独立的文件中。
Demo工程
添加依赖组件
指南的工程中需要使用jpa, spring security, h2 database, sprint boot test, spring security test,lombok,mockmvc,google guava,因此,工程的pom.xml 中需要添加如下依赖:
- <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-security</artifactId>
- </dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- </dependency>
-
- <dependency>
- <groupId>com.h2database</groupId>
- <artifactId>h2</artifactId>
- <scope>runtime</scope>
- </dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-configuration-processor</artifactId>
- <optional>true</optional>
- </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.restdocs</groupId>
- <artifactId>spring-restdocs-mockmvc</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.springframework.security</groupId>
- <artifactId>spring-security-test</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
- <version>18.0</version>
- </dependency>
- </dependencies>
用户管理
这个工程首先要做的是构建用户管理和安全。
首先创建User领域类:
User.java
- package com.babyrabbit.demo.user;
-
- import lombok.AllArgsConstructor;
- import lombok.Data;
-
- import java.util.Set;
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- @Data
- @AllArgsConstructor
- public class User {
- private UUID id;
- private String email;
- private String password;
- private Set<UserRole> roles;
- }
UserRole类是定义用户的角色:
UserRole.java
- package com.babyrabbit.demo.user;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public enum UserRole {
- OFFICER,CAPTAIN,ADMIN
- }
通常我们会把用户信息存储在数据库里,这首先要根据JPA规范把User添加上数据库存储信息:
User.java
- package com.babyrabbit.demo.user;
-
- import lombok.AllArgsConstructor;
- import lombok.Data;
- import lombok.NoArgsConstructor;
-
- import javax.persistence.*;
- import javax.validation.constraints.NotNull;
- import java.util.Set;
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- @Data
- @AllArgsConstructor
- @NoArgsConstructor
- @Entity
- @Table(name="tb_user")
- public class User {
- @Id
- private UUID id;
- private String email;
- private String password;
-
- @ElementCollection(fetch = FetchType.EAGER)
- @Enumerated(EnumType.STRING)
- @NotNull
- private Set<UserRole> roles;
- }
在这个新版本的User类中,有下面几点变化:
- 使用@Entity和@Table将User类变成JPA实体
- 使用@Id将id字段标注为主键
- 由于jpa要求所有的实体对象有无参数的构造方法,在类上添加了@NoArgsConstructor自动生成无参数的构造方法
User仓库
要存储User,需要使用存储仓库。
UserRepository.java
- package com.babyrabbit.demo.user;
-
- import org.springframework.data.repository.CrudRepository;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public interface UserRepository extends CrudRepository<User,UUID>{
- }
-
到此为止,已经有比较完备的用户管理核心部分了。有了这么多代码,先创建个测试,检测代码的正确度。
UserRepositoryTest.java
- package com.babyrabbit.demo.user;
-
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
- import org.springframework.test.context.junit4.SpringRunner;
- import java.util.HashSet;
- import java.util.UUID;
-
- import static org.assertj.core.api.Assertions.assertThat;
-
- @RunWith(SpringRunner.class)
- @DataJpaTest
- public class UserRepositoryTest {
- @Autowired
- private UserRepository repository;
-
- @Test
- public void testStoreUser() {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.OFFICER);
- User user = repository.save(new User(UUID.randomUUID(),
- "alex.foley@beverly-hills.com",
- "my-secret-pwd",
- roles));
-
- assertThat(user).isNotNull();
- assertThat(repository.count()).isEqualTo(1L);
- }
- }
上面的测试代码中,
- @DataJpaTest告诉测试系统,这是个与jpa有关的测试
- 使用注入的方式将UserRepository注入到了这个测试中
重构User
上面的User亦可以完成用户信息的存储,但是有三个可以改进的地方:
- 使用专门的类来表示主键
- 为所有实体类抽象一个父类,使用统一方式来定义主键
- 将生成主键的逻辑集中放在仓库的一个地方
在上面的代码中,使用UUID作为实体类的主键,但是如果使用专门的类而不是long或者UUID来做主键,有一下好处:
- 能更清楚的表达含义,例如,使用UserId类作为User的主键,这样比UUID或者long更能表达含义。
- 对于特定的实体使用特定类型的主键,可以防止主键混用,也就是不能把Uer的主键UserId赋值给Department。
- 当可能会修改主键的类型时,使用专门的主键类可以减少底层键值类型变化所带来的影响。
下面就为主键和实体创建父类。
首先定义主键类接口EntityId:
EntityId.java
- package com.babyrabbit.demo.orm.jpa;
-
- import java.io.Serializable;
- /**
- * Interface for primary keys of entities.
- *
- * @param <T> the underlying type of the entity id
- */
- public interface EntityId<T> extends Serializable {
- T getId();
- String asString();
- }
定义主键父类AbstractEntityId,实现主键接口EntityId:
AbstractEntityId.java
- package com.babyrabbit.demo.orm.jpa;
-
- import com.babyrabbit.demo.util.ArtifactForFramework;
- import lombok.Data;
-
- import javax.persistence.MappedSuperclass;
- import java.io.Serializable;
- import java.util.Objects;
-
- import static com.google.common.base.MoreObjects.toStringHelper;
-
- @Data
- @MappedSuperclass
- public abstract class AbstractEntityId<T extends Serializable>
- implements Serializable,EntityId<T> {
-
- private T id;
-
- @ArtifactForFramework
- protected AbstractEntityId() {
- }
-
- protected AbstractEntityId(T id) {
- this.id = Objects.requireNonNull(id);
- }
-
- @Override
- public boolean equals(Object o) {
- boolean result = false;
- if (this == o) {
- result = true;
- } else if (o instanceof AbstractEntityId) {
- AbstractEntityId other = (AbstractEntityId) o;
- result = Objects.equals(id, other.id);
- }
- return result;
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(id);
- }
-
- @Override
- public String toString() {
- return toStringHelper(this)
- .add("id", id)
- .toString();
- }
- }
上面的代码中,@MappedSuperclass是jpa提供的一个注解,通过这个注解,可以将该实体类当成基类实体,它不会隐射到数据库表,但继承它的子类实体在隐射时会自动扫描该基类实体的隐射属性,添加到子类实体的对应数据库表中。
这个注解使用规则如下:
- 使用在父类上面,是用来标识父类的
- 使用这个注解标识的类表示其不能映射到数据库表,因为其不是一个完整的实体类,但是它所拥有的属性能够隐射在其子类对用的数据库表中
- 使用这个注解标识得类不能再有@Entity或@Table注解
下面,为实体类定义公共接口Entity:
Entity.java
- package com.babyrabbit.demo.orm.jpa;
-
- /**
- * Interface for entity objects.
- *
- * @param <T> the type of {@link EntityId} that will be used in this entity
- */
- public interface Entity<T extends EntityId> {
- T getId();
- }
为实体类定义公共父类AbstractEntity,实现Entity接口:
AbstractEntity.java
- package com.babyrabbit.demo.orm.jpa;
-
- import com.babyrabbit.demo.util.ArtifactForFramework;
- import lombok.Getter;
-
- import javax.persistence.EmbeddedId;
- import javax.persistence.MappedSuperclass;
- import java.util.Objects;
-
- import static com.google.common.base.MoreObjects.toStringHelper;
-
- /**
- * Abstract super class for entities. We are assuming that early primary key
- * generation will be used.
- *
- * @param <T> the type of {@link EntityId} that will be used for this entity
- */
- @Getter
- @MappedSuperclass
- public abstract class AbstractEntity<T extends EntityId> implements Entity<T> {
-
- @EmbeddedId
- private T id;
-
- @ArtifactForFramework
- protected AbstractEntity() {
- }
-
- @Override
- public boolean equals(Object obj) {
- boolean result = false;
- if (this == obj) {
- result = true;
- } else if (obj instanceof AbstractEntity) {
- AbstractEntity other = (AbstractEntity) obj;
- result = Objects.equals(id, other.id);
- }
- return result;
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(id);
- }
-
- @Override
- public String toString() {
- return toStringHelper(this)
- .add("id", id)
- .toString();
- }
- }
上面代码中,@EmbeddedId是jpa的注解,这个注解告诉jpa,这个主键类是不需要单独生成数据表存储的。
有个自定义注解@ArtifactForFramework,这个注解只是起标记说明的作用,标记这个只是因为框架需要这样一个构造方法,在代码中没有什么使用场景。
ArtifactForFramework.java
- package com.babyrabbit.demo.util;
-
- import java.lang.annotation.Retention;
- import java.lang.annotation.RetentionPolicy;
-
- @Retention(value = RetentionPolicy.SOURCE)
- public @interface ArtifactForFramework {
- }
创建和实体和主键的父类后,就可以重构User类。 创建UserId类:
UserId.java
- import com.babyrabbit.demo.orm.jpa.AbstractEntityId;
-
- import java.util.UUID;
-
- public class UserId extends AbstractEntityId<UUID> {
- protected UserId(){
-
- }
-
- public UserId(UUID id){
- super(id);
- }
-
- @Override
- public String asString() {
- return super.getId().toString();
- }
- }
重构后的User类如下:
User.java
- package com.babyrabbit.demo.user;
-
- import com.babyrabbit.demo.orm.jpa.AbstractEntity;
- import lombok.Getter;
-
- import javax.persistence.*;
- import javax.validation.constraints.NotNull;
- import java.util.HashSet;
- import java.util.Set;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- @Getter
- @Entity
- @Table(name="tb_user")
- public class User extends AbstractEntity<UserId>{
- private String email;
- private String password;
-
- @ElementCollection(fetch = FetchType.EAGER)
- @Enumerated(EnumType.STRING)
- @NotNull
- private Set<UserRole> roles;
-
- protected User(){}
-
- public User(UserId id, String email, String password, Set<UserRole> roles) {
- super(id);
- this.email = email;
- this.password = password;
- this.roles = roles;
- }
-
-
- public static User createOfficer(UserId userId, String officerEmail, String encode) {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.OFFICER);
- return new User(userId,officerEmail,encode,roles);
- }
-
- public static User createCaptain(UserId userId, String captainEmail, String encode) {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.CAPTAIN);
- return new User(userId,captainEmail,encode,roles);
- }
- }
-
自定义主键生成逻辑
主键生成逻辑需要放在仓库中,因此需要在UserRepository中提供nextId()方法来生成UserId实例。由于UserRepository是接口,所以不能直接在UserRepository中提供nextId()的实现,因此需要做点额外工作,在一个额外实现类中实现这个功能.
自定义UserRepositoryCustom接口:
UserRepositoryCustom.java
- package com.babyrabbit.demo.user;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public interface UserRepositoryCustom {
- UserId nextId();
- }
然后将UserRepositoryCustom添加到UserRepository接口中:
UserRepository.java
- package com.babyrabbit.demo.user;
-
- import org.springframework.data.repository.CrudRepository;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public interface UserRepository extends CrudRepository<User,UUID>, UserRepositoryCustom{
- }
下面为UserRepositoryCustom接口创建实现类UserRepositoryImpl,实现nextId()的逻辑:
UserRepositoryImpl.java
- package com.babyrabbit.demo.user;
-
-
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
-
- import java.util.UUID;
-
- public class UserRepositoryImpl implements UserRepositoryCustom {
-
- private final UniqueIdGenerator<UUID> generator;
-
- public UserRepositoryImpl(UniqueIdGenerator<UUID> generator) {
- this.generator = generator;
- }
-
- @Override
- public UserId nextId() {
- return new UserId(generator.getNextUniqueId());
- }
- }
当程序运行时,spring data会将UserRepositoryImpl的代码与Spring data的CrudRepository的代码合并,在UserRepository中会看到UserRepositoryCustom和CrudRepository里的方法。
UserRepositoryImpl中注入了UniqueIdGenerator,因此需要创建UniqueIdGenerator,并注册为spring bean.
UniqueIdGenerator.java
- package com.babyrabbit.demo.orm.jpa;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public interface UniqueIdGenerator<T> {
- T getNextUniqueId();
- }
InMemoryUniqueIdGenerator.java
- package com.babyrabbit.demo.orm.jpa;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public class InMemoryUniqueIdGenerator implements UniqueIdGenerator<UUID>{
- @Override
- public UUID getNextUniqueId() {
- return UUID.randomUUID();
- }
- }
在BabyRabbitConfiguration中,将UniqueIdGenerator注册到spring中:
BabyRabbitConfiguration.java
- package com.babyrabbit.demo.conf;
-
- import com.babyrabbit.demo.orm.jpa.InMemoryUniqueIdGenerator;
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Configuration;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- @Configuration
- public class BabyRabbitConfiguration {
- @Bean
- public UniqueIdGenerator<UUID> uniqueIdGenerator() {
- return new InMemoryUniqueIdGenerator();
- }
- }
重构了ID的定义后,UserRepositoryTest也需要做点调整才能通过测试:
UserRepositoryTest.java
- package com.babyrabbit.demo.user;
-
- import com.babyrabbit.demo.orm.jpa.InMemoryUniqueIdGenerator;
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
- import org.springframework.boot.test.context.TestConfiguration;
- import org.springframework.context.annotation.Bean;
- import org.springframework.test.context.junit4.SpringRunner;
- import java.util.HashSet;
- import java.util.UUID;
-
- import static org.assertj.core.api.Assertions.assertThat;
-
- @RunWith(SpringRunner.class)
- @DataJpaTest
- public class UserRepositoryTest {
- @Autowired
- private UserRepository repository;
-
- @Test
- public void testStoreUser() {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.OFFICER);
- User user = repository.save(new User(repository.nextId(),
- "alex.foley@beverly-hills.com",
- "my-secret-pwd",
- roles));
- assertThat(user).isNotNull();
- assertThat(repository.count()).isEqualTo(1L);
- }
-
- @TestConfiguration
- static class TestConfig {
- @Bean
- public UniqueIdGenerator<UUID> generator() {
- return new InMemoryUniqueIdGenerator();
- }
- }
- }
REST API 的安全
Spring Security提供了全套的安全解决方案。对于移动应用,将使用Oauth2安全方案。Spring Security Oauth2提供了Oauth2实现。
注意: Oauth2需要使用HTTPS协议。
使用Oauth2安全方案后,可以通过client secret和client id 来验证client应用,使用username, password来验证用户。相应的,验证过的用户可以获取access token来访问API。
为了使用spring security oauth2,需要使用AutoConfigure来简化oauth2的配置,这需要在pom.xml中添加依赖:
- <dependency>
- <groupId>org.springframework.security.oauth.boot</groupId>
- <artifactId>spring-security-oauth2-autoconfigure</artifactId>
- <version>2.0.1.RELEASE</version>
- </dependency>
Oauth协议中,提供了两部分组件:
- 授权服务:负责给客户端和用户授权
- 资源服务:负责定义认证用户和未认证用户可以访问应用的哪些部分
创建OAuth2ServerConfiguration来配置授权和资源服务:
OAuth2ServerConfiguration.java
- package com.babyrabbit.demo.conf;
-
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.http.HttpMethod;
- import org.springframework.security.authentication.AuthenticationManager;
- import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
- import org.springframework.security.config.annotation.web.builders.HttpSecurity;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
- import org.springframework.security.oauth2.provider.token.TokenStore;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public class OAuth2ServerConfiguration {
-
- @Value("${resource_id}")
- private String resourceId;
-
- @Configuration
- @EnableAuthorizationServer
- protected class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
-
- @Autowired
- private AuthenticationManager authenticationManager;
-
- @Autowired
- private UserDetailsService userDetailsService;
-
- @Autowired
- private PasswordEncoder passwordEncoder;
-
- @Autowired
- private TokenStore tokenStore;
-
- @Override
- public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
- security.passwordEncoder(passwordEncoder);
- }
-
- @Override
- public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
- clients.inMemory()
- .withClient("baby-mobile-client")
- .authorizedGrantTypes("password", "refresh_token")
- .scopes("mobile_app")
- .resourceIds(resourceId)
- .secret(passwordEncoder.encode("ccUyb6vS4S8nxfbKPCrN"));
- }
-
- @Override
- public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
- endpoints.tokenStore(tokenStore)
- .authenticationManager(authenticationManager)
- .userDetailsService(userDetailsService);
- }
- }
-
- @Configuration
- @EnableResourceServer
- @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
- protected class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
-
- @Override
- public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
- resources.resourceId(resourceId);
- }
-
- @Override
- public void configure(HttpSecurity http) throws Exception {
- http.authorizeRequests()
- .antMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()
- .and()
- .antMatcher("/api/**").authorizeRequests()
- .anyRequest().authenticated();
- }
- }
- }
上面AuthorizationServerConfiguration代码中:
- 只对ID是baby-mobile-client的客户端做认证
- 支持password和refresh_token两种认证模式
- scopes定义了可以通过access token访问的程序部分
上面ResourceServerConfiguration代码中:
- 允许所有对于 '/api/**' 的Options操作
- 其他访问 '/api/**' 的操作,需要授权
- 所有对 '/api/**' 的非OPTIONS访问都需要认证
上面的代码中需要注入PasswordEncoder和 TokenStore, 因此,需要在Spring中注册这两个bean:
- package com.babyrabbit.demo.conf;
-
- import com.babyrabbit.demo.orm.jpa.InMemoryUniqueIdGenerator;
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.security.oauth2.provider.token.TokenStore;
- import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- @Configuration
- public class BabyRabbitConfiguration {
- @Bean
- public UniqueIdGenerator<UUID> uniqueIdGenerator() {
- return new InMemoryUniqueIdGenerator();
- }
-
- @Bean
- public PasswordEncoder passwordEncoder() {
- return new BCryptPasswordEncoder();
- }
- @Bean
- public TokenStore tokenStore() {
- return new InMemoryTokenStore();
- }
- }
UserDetailsService
为了实现认证功能,需要实现UserDetailsService。这个接口只有一个方法 UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
UserDetails
这里,先要构建一个UserDetails实现。
ApplicationUserDetails.java
- package com.babyrabbit.demo.security;
-
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.UserId;
- import com.babyrabbit.demo.user.UserRole;
- import org.springframework.security.core.authority.SimpleGrantedAuthority;
- import java.util.Collection;
- import java.util.Set;
- import java.util.stream.Collectors;
-
- public class ApplicationUserDetails extends org.springframework.security.core.userdetails.User {
- private static final String ROLE_PREFIX = "ROLE_";
-
- private final UserId userId;
-
- public ApplicationUserDetails(User user) {
- super(user.getEmail(), user.getPassword(), createAuthorities(user.getRoles()));
- this.userId = user.getId();
- }
-
- public UserId getUserId() {
- return userId;
- }
-
- private static Collection<SimpleGrantedAuthority> createAuthorities(Set<UserRole> roles) {
- return roles.stream()
- .map(userRole -> new SimpleGrantedAuthority(ROLE_PREFIX + userRole.name()))
- .collect(Collectors.toSet());
- }
- }
自定义的UserDetails继承了 org.springframework.security.core.userdetails.User
.
当然,也可以直接在User对象里实现UserDetails:
public class User extends AbstractEntity<UserId> implements UserDetails {
如果要这么做,需要注意三点:
- getAuthorities()方法返回这个用户的授权列表。可以参照
org.springframework.security.core.userdetails.User
里的实现 - User需要实现序列化
- Spring Security 会缓存UserDetails,因此在控制器中,使用
@AuthenticationPrincipal User user
注入已认证用户对象
UserRepository
要实现能读取用户信息的能力,需要在用户仓库里增加一个方法:
UserRepository.java
- package com.babyrabbit.demo.user;
-
- import org.springframework.data.repository.CrudRepository;
-
- import java.util.Optional;
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/13.
- */
- public interface UserRepository extends CrudRepository<User,UUID>, UserRepositoryCustom{
- Optional<User> findByEmailIgnoreCase(String email);
- }
这里用了Optional作为返回类型,无论findByEmailIgnoreCase方法是否找到user的信息,都不会直接返回null给调用者,这样防止了空指针例外。
相应的,修改UserRepositoryTest.java,测试这个新方法:
UserRepositoryTest.java
- package com.babyrabbit.demo.user;
-
- import com.babyrabbit.demo.orm.jpa.InMemoryUniqueIdGenerator;
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
- import org.springframework.boot.test.context.TestConfiguration;
- import org.springframework.context.annotation.Bean;
- import org.springframework.test.context.junit4.SpringRunner;
- import java.util.HashSet;
- import java.util.Locale;
- import java.util.Optional;
- import java.util.UUID;
-
- import static org.assertj.core.api.Assertions.assertThat;
-
- @RunWith(SpringRunner.class)
- @DataJpaTest
- public class UserRepositoryTest {
- @Autowired
- private UserRepository repository;
-
- @Test
- public void testStoreUser() {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.OFFICER);
- User user = repository.save(new User(repository.nextId(),
- "alex.foley@beverly-hills.com",
- "my-secret-pwd",
- roles));
- assertThat(user).isNotNull();
- assertThat(repository.count()).isEqualTo(1L);
- }
-
- @Test
- public void testFindByEmail() {
- User user = Users.newRandomOfficer();
- repository.save(user);
- Optional<User> optional = repository.findByEmailIgnoreCase(user.getEmail());
- assertThat(optional).isNotEmpty()
- .contains(user);
- }
- @Test
- public void testFindByEmailIgnoringCase() {
- User user = Users.newRandomOfficer();
- repository.save(user);
- Optional<User> optional = repository.findByEmailIgnoreCase(user.getEmail()
- .toUpperCase
- (Locale.US));
- assertThat(optional).isNotEmpty()
- .contains(user);
- }
- @Test
- public void testFindByEmail_unknownEmail() {
- User user = Users.newRandomOfficer();
- repository.save(user);
- Optional<User> optional = repository.findByEmailIgnoreCase("will.not@find.me");
- assertThat(optional).isEmpty();
- }
-
- @TestConfiguration
- static class TestConfig {
- @Bean
- public UniqueIdGenerator<UUID> generator() {
- return new InMemoryUniqueIdGenerator();
- }
- }
- }
上面的测试代码中,新增了三个测试: testFindByEmail, testFindByEmailIgnoringCase, testFindByEmail_unknownEmail . 在新增的测试中,使用了Users这个类,这是一个工厂类,用来构造User。 我喜欢为每个实体对象都准备一个工厂类,这样就可以把构造实体对象的逻辑集中化。
ApplicationUserDetailsService
下面创建ApplicationUserDetailsService.java, 实现UserDetailsService, 提供加载user信息的服务。
ApplicationUserDetailsService.java
- package com.babyrabbit.demo.security;
-
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.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 java.lang.String.format;
- @Service
- public class ApplicationUserDetailsService implements UserDetailsService {
- private final UserRepository userRepository;
- @Autowired
- public ApplicationUserDetailsService(UserRepository userRepository) {
- this.userRepository = userRepository;
- }
- @Override
- public UserDetails loadUserByUsername(String username) {
- User user = userRepository.findByEmailIgnoreCase(username)
- .orElseThrow(() -> new UsernameNotFoundException(
- String.format("User with email %s could not be found",
- username)));
- return new ApplicationUserDetails(user);
- }
- }
由于User仓库的findByEmailIgnoreCase 方法返回的是Optional类型的返回值,因此当Optional中没有期望的值时,可以使用orElseThrow 来抛出例外终止代码执行流程。
已经创建了ApplicationUserDetailsService,下面就可以为其创建测试了。
ApplicationUserDetailsServiceTest.java
- package com.babyrabbit.demo.security;
-
- import com.babyrabbit.demo.user.UserRepository;
- import com.babyrabbit.demo.user.Users;
- import org.junit.Test;
- import org.springframework.security.core.GrantedAuthority;
- import org.springframework.security.core.userdetails.UserDetails;
- import org.springframework.security.core.userdetails.UsernameNotFoundException;
- import java.util.Optional;
- import static org.assertj.core.api.Assertions.assertThat;
- import static org.mockito.Matchers.anyString;
- import static org.mockito.Mockito.mock;
- import static org.mockito.Mockito.when;
-
- public class ApplicationUserDetailsServiceTest {
- @Test
- public void givenExistingUsername_whenLoadingUser_userIsReturned() {
- UserRepository repository = mock(UserRepository.class);
- ApplicationUserDetailsService service = new ApplicationUserDetailsService(repository);
-
- when(repository.findByEmailIgnoreCase(Users.OFFICER_EMAIL))
- .thenReturn(Optional
- .of(Users.officer()));
-
- UserDetails userDetails = service.loadUserByUsername(Users.OFFICER_EMAIL);
-
- assertThat(userDetails).isNotNull();
- assertThat(userDetails.getUsername()).isEqualTo(Users.OFFICER_EMAIL);
- assertThat(userDetails.getAuthorities()).extracting(GrantedAuthority::getAuthority).contains("ROLE_OFFICER");
- assertThat(userDetails).isInstanceOfSatisfying(ApplicationUserDetails.class,
- applicationUserDetails -> {
- assertThat(applicationUserDetails.getUserId()).isEqualTo(Users.officer().getId());
- });
- }
-
- @Test(expected = UsernameNotFoundException.class)
- public void givenNotExistingUsername_whenLoadingUser_exceptionThrown() {
- UserRepository repository = mock(UserRepository.class);
- ApplicationUserDetailsService service = new ApplicationUserDetailsService(repository);
-
- when(repository.findByEmailIgnoreCase(anyString())).thenReturn(Optional.empty());
-
- service.loadUserByUsername("i@donotexist.com");
- }
- }
测试获取access token
前面已经把Spring Security OAuth2集成到工程里了,下面就可以尝试获取access token. 在执行测试前,需要先在库里创建几个User。
DevelopmentDbInitializer
DevelopmentDbInitializer类实现了ApplicationRunner接口,spring boot在启动时,会执行所有这个接口的实现类。
DevelopmentDbInitializer.java
- package com.babyrabbit.demo;
-
-
- import com.babyrabbit.demo.user.UserService;
- import com.babyrabbit.demo.util.SpringProfiles;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.ApplicationArguments;
- import org.springframework.boot.ApplicationRunner;
- import org.springframework.context.annotation.Profile;
- import org.springframework.stereotype.Component;
-
- @Component
- @Profile(SpringProfiles.DEV)
- public class DevelopmentDbInitializer implements ApplicationRunner {
-
- private final UserService userService;
-
- @Autowired
- public DevelopmentDbInitializer(UserService userService) {
- this.userService = userService;
- }
-
- @Override
- public void run(ApplicationArguments applicationArguments) {
- createTestUsers();
- }
-
- private void createTestUsers() {
- userService.createOfficer("officer@example.com", "officer");
- }
- }
UserService
UserService完成与User有关业务逻辑的服务接口。
UserService.java
- package com.babyrabbit.demo.user;
-
- public interface UserService {
- User createOfficer(String email, String password);
- }
-
UserServiceImpl.java
- package com.babyrabbit.demo.user;
-
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.stereotype.Service;
-
- @Service
- public class UserServiceImpl implements UserService {
- private final UserRepository repository;
- private final PasswordEncoder passwordEncoder;
-
- @Autowired
- public UserServiceImpl(UserRepository repository, PasswordEncoder passwordEncoder) {
- this.repository = repository;
- this.passwordEncoder = passwordEncoder;
- }
-
- @Override
- public User createOfficer(String email, String password) {
- User user = User.createOfficer(repository.nextId(), email,
- passwordEncoder.encode(password));
- return repository.save(user);
- }
- }
下面使用maven 启动spring boot 服务: mvn spring-boot:run --spring.profiles.active=dev
使用POSTMAN工具来测试服务:
- 选择POST请求
- 地址为: http://localhost:8080/oauth/token
- 在Basic Auth选项页中,username: baby-mobile-client, password: ccUyb6vS4S8nxfbKPCrN
- Priview Request, 在Body选项页,选择x-www-form-urlencoded,填入下面的值:
key | value |
---|---|
username | officer@example.com |
password | officer |
grant_type | password |
client_id | baby-mobile-client |
client_secret | ccUyb6vS4S8nxfbKPCrN |
测试成,服务返回数据:
- {
- "access_token": "4329339a-ea9b-4781-86d1-1f49193fa5b3",
- "token_type": "bearer",
- "refresh_token": "90d46c41-9aa0-4c23-88e3-8d4ca4e77b30",
- "expires_in": 43199,
- "scope": "mobile_app"
- }
在返回结果中,包含了access_token和refresh_token。access_token是我们在访问服务的时候需要用的。当access_token过期后,可以使用refresh_token获取一个新的access_token,而不需要重新提供用户名密码进行验证。 当然,refresh_token也会过期,这时候就需要用户重新认证,获取新的access_token和refresh_token。
为了能自动测试 /oauth/token
接口,需要准备点单元测试,这样在每次修改代码后,都会执行单元测试验证接口的正确性。
OAuth2ServerConfigurationTest.java
- package com.babyrabbit.demo.security;
-
- import com.babyrabbit.demo.user.UserService;
- import com.babyrabbit.demo.user.Users;
- import com.babyrabbit.demo.util.SpringProfiles;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
- import org.springframework.boot.test.context.SpringBootTest;
- import org.springframework.test.context.ActiveProfiles;
- import org.springframework.test.context.junit4.SpringRunner;
- import org.springframework.test.web.servlet.MockMvc;
- import org.springframework.util.LinkedMultiValueMap;
- import org.springframework.util.MultiValueMap;
-
- import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
- import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
- import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
- @RunWith(SpringRunner.class)
- @SpringBootTest
- @AutoConfigureMockMvc
- @ActiveProfiles(SpringProfiles.TEST)
- public class OAuth2ServerConfigurationTest {
-
- @Autowired
- private MockMvc mvc;
-
- @Autowired
- private UserService userService;
-
- @Test
- public void testGetAccessTokenAsOfficer() throws Exception {
- userService.createOfficer(Users.OFFICER_EMAIL, Users.OFFICER_PASSWORD);
- String clientId = "baby-mobile-client";
- String clientSecret = "ccUyb6vS4S8nxfbKPCrN";
- MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
- params.add("grant_type", "password");
- params.add("client_id", clientId);
- params.add("client_secret", clientSecret);
- params.add("username", Users.OFFICER_EMAIL);
- params.add("password", Users.OFFICER_PASSWORD);
- mvc.perform(post("/oauth/token")
- .params(params)
- .with(httpBasic(clientId, clientSecret))
- .accept("application/json;charset=UTF-8"))
- .andExpect(status().isOk())
- .andExpect(content().contentType("application/json;charset=UTF-8"))
- .andDo(print())
- .andExpect(jsonPath("access_token").isString())
- .andExpect(jsonPath("token_type").value("bearer"))
- .andExpect(jsonPath("refresh_token").isString())
- .andExpect(jsonPath("expires_in").isNumber())
- .andExpect(jsonPath("scope").value("mobile_app"));
- }
- }
上面的代码中:
- @SpringBootTest 要求测试需要一个mock servlet 环境
- @AutoConfigureMockMvc 标记这个mock servlet环境会被自动配置,不需要为了这个测试做特殊配置
- 自动注入MockMvc, 可以用来测试API接口
把自定义的配置分离出来
在这个工程中,有一些自定义的属性,例如,client_id, client_secret等,这些配置是与安全相关的,这些信息最好放在配置文件了,而不是硬编码在代码中。spring boot对这样的需求提供了支持。
首先,需要在工程pom.xml中增减依赖:
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-configuration-processor</artifactId>
- <optional>true</optional>
- </dependency>
然后,创建一个POJO来存储这两个属性:
SecurityConfiguration.java
- package com.babyrabbit.demo.conf;
-
- import lombok.Data;
- import org.springframework.boot.context.properties.ConfigurationProperties;
- import org.springframework.stereotype.Component;
-
- @Data
- @Component
- @ConfigurationProperties(prefix = "babyrabbit-security")
- public class SecurityConfiguration {
- private String mobileAppClientId;
- private String mobileAppClientSecret;
- }
@ConfigurationProperties注解告诉配置处理器这个类的属性字段对于外部配置是可见的。
有了这个配置后,就可以修改AuthorizationServerConfiguration, 自动注入SecurityConfiguration了:
OAuth2ServerConfiguration.java
- package com.babyrabbit.demo.conf;
-
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.http.HttpMethod;
- import org.springframework.security.authentication.AuthenticationManager;
- import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
- import org.springframework.security.config.annotation.web.builders.HttpSecurity;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
- import org.springframework.security.oauth2.provider.token.TokenStore;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- @Configuration
- public class OAuth2ServerConfiguration {
- @Value("${resource_id}")
- private String resourceId;
-
- @Configuration
- @EnableAuthorizationServer
- protected class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
-
- @Autowired
- private AuthenticationManager authenticationManager;
-
- @Autowired
- private UserDetailsService userDetailsService;
-
- @Autowired
- private PasswordEncoder passwordEncoder;
-
- @Autowired
- private TokenStore tokenStore;
-
- @Autowired
- private SecurityConfiguration securityConfiguration;
-
- @Override
- public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
- security.passwordEncoder(passwordEncoder);
- }
-
- @Override
- public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
- clients.inMemory()
- .withClient(securityConfiguration.getMobileAppClientId())
- .authorizedGrantTypes("password", "refresh_token")
- .scopes("mobile_app")
- .resourceIds(resourceId)
- .secret(passwordEncoder.encode(securityConfiguration.getMobileAppClientSecret()));
- }
-
- @Override
- public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
- endpoints.tokenStore(tokenStore)
- .authenticationManager(authenticationManager)
- .userDetailsService(userDetailsService);
- }
- }
-
- @Configuration
- @EnableResourceServer
- @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
- protected class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
-
- @Override
- public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
- resources.resourceId(resourceId);
- }
-
- @Override
- public void configure(HttpSecurity http) throws Exception {
- http.authorizeRequests()
- .antMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()
- .and()
- .antMatcher("/api/**").authorizeRequests()
- .anyRequest().authenticated();
- }
- }
- }
之后,就可以在application-dev.properties
,application-staging.properties
,application-prod.properties
,application-test.properties
中配置相应的值:
例如,在application-dev.properties中的配置如下:
- baby-security.mobile-app-client-id=baby-mobile-client
- baby-security.mobile-app-client-secret=ccUyb6vS4S8nxfbKPCrN
在application-test.properties中配置如下:
- baby-security.mobile-app-client-id=test-client-id
- baby-security.mobile-app-client-secret=test-client-secret
REST 控制器
要实现API业务服务,需要通过REST提供API 接口。
GET 接口
首先提供一个查看用户自己信息的接口,这个接口是一个GET接口。
UserRestController.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.security.ApplicationUserDetails;
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.UserNotFoundException;
- import com.babyrabbit.demo.user.UserService;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.security.core.annotation.AuthenticationPrincipal;
- import org.springframework.web.bind.annotation.GetMapping;
- import org.springframework.web.bind.annotation.RequestMapping;
- import org.springframework.web.bind.annotation.RestController;
-
- @RestController
- @RequestMapping("/api/users")
- public class UserRestController {
- private final UserService service;
-
- @Autowired
- public UserRestController(UserService service) {
- this.service = service;
- }
-
- @GetMapping("/me")
- public UserDto currentUser(@AuthenticationPrincipal ApplicationUserDetails userDetails) {
- User user = service.getUser(userDetails.getUserId())
- .orElseThrow(() -> new UserNotFoundException(userDetails.getUserId()));
- return UserDto.fromUser(user);
- }
- }
客户端可以通过 get /api/users/me
来查看登录用户的信息。
UserNotFoundException.java
- package com.babyrabbit.demo.user;
-
- import org.springframework.http.HttpStatus;
- import org.springframework.web.bind.annotation.ResponseStatus;
-
- @ResponseStatus(HttpStatus.NOT_FOUND)
- public class UserNotFoundException extends RuntimeException {
- public UserNotFoundException(UserId userId) {
- super(String.format("Could not find user with id %s", userId.asString()));
- }
- }
这个自定义例外用来在未找到用户时,打断程序的执行流程。使用@ResponseStatus注解,当这个例外从controller抛出时,spring框架会捕捉并处理,生成默认的响应,这时候,这个响应使用的状态码就是这个注解里的值。
UserDto.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.UserId;
- import com.babyrabbit.demo.user.UserRole;
- import lombok.Value;
- import java.util.Set;
-
- @Value
- public class UserDto {
-
- private final UserId id;
- private final String email;
- private final Set<UserRole> roles;
-
- public static UserDto fromUser(User user) {
- return new UserDto(user.getId(),
- user.getEmail(),
- user.getRoles());
- }
- }
不能直接使用实体对象作为返回数据的载体,使用专用的传输对象。
UserRestController的测试
为这个接口创建测试用例。
UserRestControllerTest.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.conf.OAuth2ServerConfiguration;
- import com.babyrabbit.demo.conf.SecurityConfiguration;
- import com.babyrabbit.demo.user.UserService;
- import com.babyrabbit.demo.user.Users;
- import com.babyrabbit.demo.util.SpringProfiles;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
- import org.springframework.boot.test.context.TestConfiguration;
- import org.springframework.boot.test.mock.mockito.MockBean;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Import;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.oauth2.provider.token.TokenStore;
- import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
- import org.springframework.test.context.ActiveProfiles;
- import org.springframework.test.context.junit4.SpringRunner;
- import org.springframework.test.web.servlet.MockMvc;
-
- import java.util.Optional;
-
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.HEADER_AUTHORIZATION;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.bearer;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.obtainAccessToken;
- import static org.mockito.Mockito.when;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
- @RunWith(SpringRunner.class)
- @WebMvcTest(UserRestController.class)
- @ActiveProfiles(SpringProfiles.TEST)
- public class UserRestControllerTest {
- @Autowired
- private MockMvc mvc;
- @MockBean
- private UserService service;
-
- @Test
- public void givenNotAuthenticated_whenAskingMyDetails_forbidden() throws Exception {
- mvc.perform(get("/api/users/me")).andExpect(status().isUnauthorized());
- }
-
- @Test
- public void givenAuthenticatedAsOfficer_whenAskingMyDetails_detailsReturned() throws Exception {
- String accessToken = obtainAccessToken(mvc, Users.OFFICER_EMAIL, Users.OFFICER_PASSWORD);
-
- when(service.getUser(Users.officer().getId())).thenReturn(Optional.of(Users.officer()));
-
- mvc.perform(get("/api/users/me")
- .header(HEADER_AUTHORIZATION, bearer(accessToken)))
- .andExpect(status().isOk())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("email").value(Users.OFFICER_EMAIL))
- .andExpect(jsonPath("roles").isArray())
- .andExpect(jsonPath("roles[0]").value("OFFICER"))
- ;
- }
-
- @TestConfiguration
- @Import(OAuth2ServerConfiguration.class)
- static class TestConfig {
- @Bean
- public UserDetailsService userDetailsService() {
- return new StubUserDetailsService();
- }
-
- @Bean
- public TokenStore tokenStore() {
- return new InMemoryTokenStore();
- }
-
- @Bean
- public SecurityConfiguration securityConfiguration() {
- return new SecurityConfiguration();
- }
- }
- }
上面的测试代码中,用到了下面几个类:
StubUserDetailsService.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.security.ApplicationUserDetails;
- import com.babyrabbit.demo.user.Users;
- import org.springframework.security.core.userdetails.UserDetailsService;
-
- import org.springframework.security.core.userdetails.UserDetails;
- import org.springframework.security.core.userdetails.UsernameNotFoundException;
-
- public class StubUserDetailsService implements UserDetailsService {
- @Override
- public UserDetails loadUserByUsername(String username) throws
- UsernameNotFoundException {
- switch (username) {
- case Users.OFFICER_EMAIL:
- return new ApplicationUserDetails(Users.officer());
- case Users.CAPTAIN_EMAIL:
- return new ApplicationUserDetails(Users.captain());
- default:
- throw new UsernameNotFoundException(username);
- }
- }
- }
-
SecurityHelperForMockMvc.java
- package com.babyrabbit.demo.security;
-
- import org.springframework.boot.json.JacksonJsonParser;
- import org.springframework.test.web.servlet.MockMvc;
- import org.springframework.test.web.servlet.ResultActions;
- import org.springframework.util.LinkedMultiValueMap;
- import org.springframework.util.MultiValueMap;
-
- import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
- import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
- public class SecurityHelperForMockMvc {
-
- private static final String UNIT_TEST_CLIENT_ID = "test-client-id";
- private static final String UNIT_TEST_CLIENT_SECRET = "test-client-secret";
- public static final String HEADER_AUTHORIZATION = "Authorization";
-
- /**
- * Allows to get an access token for the given user in the context of a spring (unit)
- test
- * using MockMVC.
- *
- * @param mvc the MockMvc instance
- * @param username the username
- * @param password the password
- * @return the <code>access_token</code> to be used in the <code>Authorization</code>
- header
- * @throws Exception if no token could be obtained.
- */
- public static String obtainAccessToken(MockMvc mvc, String username, String password)
- throws Exception {
- MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
- params.add("grant_type", "password");
- params.add("client_id", UNIT_TEST_CLIENT_ID);
- params.add("client_secret", UNIT_TEST_CLIENT_SECRET);
- params.add("username", username);
- params.add("password", password);
- ResultActions result = mvc.perform(post("/oauth/token")
- .params(params)
- .with(httpBasic(UNIT_TEST_CLIENT_ID,UNIT_TEST_CLIENT_SECRET))
- .accept("application/json;charset=UTF-8"))
- .andExpect(status().isOk())
- .andExpect(content().contentType("application/json;charset=UTF-8"));
- String resultString = result.andReturn().getResponse().getContentAsString();
-
- JacksonJsonParser jsonParser = new JacksonJsonParser();
- return jsonParser.parseMap(resultString).get("access_token").toString();
- }
-
- public static String bearer(String accessToken) {
- return "Bearer " + accessToken;
- }
- }
UNIT_TEST_CLIENT_ID
和 UNIT_TEST_CLIENT_SECRET
的值要与application-test.properties
里的client id
, client secret
一致。
POST 接口
为了能让mobile-app 用户创建账号,需要提供一个/api/users/
POST 接口。这个接口接收JSON类型的数据,来创建账号。
首先,为这个接口创建入参:
CreateOfficerParameters.java
- package com.babyrabbit.demo.web;
-
- import lombok.Data;
- import org.hibernate.validator.constraints.Email;
- import javax.validation.constraints.NotNull;
- import javax.validation.constraints.Size;
- @Data
- public class CreateOfficerParameters {
- @NotNull
- @Email
- private String email;
-
- @NotNull
- @Size(min = 6, max = 1000)
- private String password;
- }
在UserRestController里创建接口方法:
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.security.ApplicationUserDetails;
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.UserNotFoundException;
- import com.babyrabbit.demo.user.UserService;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.http.HttpStatus;
- import org.springframework.security.core.annotation.AuthenticationPrincipal;
- import org.springframework.web.bind.annotation.*;
-
- import javax.validation.Valid;
-
- @RestController
- @RequestMapping("/api/users")
- public class UserRestController {
- private final UserService service;
-
- @Autowired
- public UserRestController(UserService service) {
- this.service = service;
- }
-
- @GetMapping("/me")
- public UserDto currentUser(@AuthenticationPrincipal ApplicationUserDetails userDetails) {
- User user = service.getUser(userDetails.getUserId())
- .orElseThrow(() -> new UserNotFoundException(userDetails.getUserId()));
- return UserDto.fromUser(user);
- }
-
- @PostMapping
- @ResponseStatus(HttpStatus.CREATED)
- public UserDto createOfficer(@Valid @RequestBody CreateOfficerParameters parameters)
- {
- User officer = service.createOfficer(parameters.getEmail(), parameters.getPassword());
- return UserDto.fromUser(officer);
- }
- }
-
下面就为这个接口构建测试代码:
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.conf.OAuth2ServerConfiguration;
- import com.babyrabbit.demo.conf.SecurityConfiguration;
- import com.babyrabbit.demo.user.UserService;
- import com.babyrabbit.demo.user.Users;
- import com.babyrabbit.demo.util.SpringProfiles;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
- import org.springframework.boot.test.context.TestConfiguration;
- import org.springframework.boot.test.mock.mockito.MockBean;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Import;
- import org.springframework.http.MediaType;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.oauth2.provider.token.TokenStore;
- import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
- import org.springframework.test.context.ActiveProfiles;
- import org.springframework.test.context.junit4.SpringRunner;
- import org.springframework.test.web.servlet.MockMvc;
-
- import java.util.Optional;
-
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.HEADER_AUTHORIZATION;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.bearer;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.obtainAccessToken;
- import static org.mockito.Mockito.verify;
- import static org.mockito.Mockito.when;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
- @RunWith(SpringRunner.class)
- @WebMvcTest(UserRestController.class)
- @ActiveProfiles(SpringProfiles.TEST)
- public class UserRestControllerTest {
- @Autowired
- private MockMvc mvc;
- @MockBean
- private UserService service;
-
- @Autowired
- private ObjectMapper objectMapper;
-
- @Test
- public void givenNotAuthenticated_whenAskingMyDetails_forbidden() throws Exception {
- mvc.perform(get("/api/users/me")).andExpect(status().isUnauthorized());
- }
-
- @Test
- public void givenAuthenticatedAsOfficer_whenAskingMyDetails_detailsReturned() throws Exception {
- String accessToken = obtainAccessToken(mvc, Users.OFFICER_EMAIL, Users.OFFICER_PASSWORD);
-
- when(service.getUser(Users.officer().getId())).thenReturn(Optional.of(Users.officer()));
-
- mvc.perform(get("/api/users/me")
- .header(HEADER_AUTHORIZATION, bearer(accessToken)))
- .andExpect(status().isOk())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("email").value(Users.OFFICER_EMAIL))
- .andExpect(jsonPath("roles").isArray())
- .andExpect(jsonPath("roles[0]").value("OFFICER"))
- ;
- }
-
- @Test
- public void testCreateOfficer() throws Exception {
- String email = "wim.deblauwe@example.com";
- String password = "my-super-secret-pwd";
-
- CreateOfficerParameters parameters = new CreateOfficerParameters();
- parameters.setEmail(email);
- parameters.setPassword(password);
-
- when(service.createOfficer(email, password)).thenReturn(Users.newOfficer(email,password));
-
- mvc.perform(post("/api/users")
- .contentType(MediaType.APPLICATION_JSON_UTF8)
- .content(objectMapper.writeValueAsString(parameters)))
- .andExpect(status().isCreated())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("email").value(email))
- .andExpect(jsonPath("roles").isArray())
- .andExpect(jsonPath("roles[0]").value("OFFICER"));
-
- verify(service).createOfficer(email, password);
- }
-
- @TestConfiguration
- @Import(OAuth2ServerConfiguration.class)
- static class TestConfig {
- @Bean
- public UserDetailsService userDetailsService() {
- return new StubUserDetailsService();
- }
-
- @Bean
- public TokenStore tokenStore() {
- return new InMemoryTokenStore();
- }
-
- @Bean
- public SecurityConfiguration securityConfiguration() {
- return new SecurityConfiguration();
- }
- }
- }
需要在Users中增加一个createOfficer方法:
- package com.babyrabbit.demo.user;
-
- import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
- import org.springframework.security.crypto.password.PasswordEncoder;
-
- import java.util.HashSet;
- import java.util.UUID;
-
- public class Users {
- private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
-
- public static final String OFFICER_EMAIL = "officer@example.com";
- public static final String OFFICER_PASSWORD = "officer";
- public static final String CAPTAIN_EMAIL = "captain@example.com";
- public static final String CAPTAIN_PASSWORD = "captain";
-
- private static User OFFICER = User.createOfficer(newRandomId(),
- OFFICER_EMAIL,
- PASSWORD_ENCODER.encode(OFFICER_PASSWORD));
-
- private static User CAPTAIN = User.createCaptain(newRandomId(),
- CAPTAIN_EMAIL,
- PASSWORD_ENCODER.encode(CAPTAIN_PASSWORD));
-
- public static UserId newRandomId() {
- return new UserId(UUID.randomUUID());
- }
-
- public static User newRandomOfficer() {
- return newRandomOfficer(newRandomId());
- }
-
- public static User newRandomOfficer(UserId userId) {
- String uniqueId = userId.asString().substring(0, 5);
- return User.createOfficer(userId,
- "user-" + uniqueId + "@example.com",
- PASSWORD_ENCODER.encode("user"));
- }
-
- public static User officer() {
- return OFFICER;
- }
-
- public static User captain() {
- return CAPTAIN;
- }
-
- private Users() {
- }
-
- public static User newOfficer(String email, String password) {
- HashSet<UserRole> roles = new HashSet<>();
- roles.add(UserRole.OFFICER);
- return new User(new UserId(UUID.randomUUID()),email,PASSWORD_ENCODER.encode(password),roles);
- }
- }
执行这个测试用例会返回"401 Unauthorized"错误,这是因为Spring Security Oauth 资源服务中配置的队所有/api
接口访问都需要认证,而创建账号时是无法认证的,因此,需要修改资源服务的配置,允许/api/users
的post请求访问:
OAuth2ServerConfiguration.java
- package com.babyrabbit.demo.conf;
-
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.http.HttpMethod;
- import org.springframework.security.authentication.AuthenticationManager;
- import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
- import org.springframework.security.config.annotation.web.builders.HttpSecurity;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
- import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
- import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
- import org.springframework.security.oauth2.provider.token.TokenStore;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- @Configuration
- public class OAuth2ServerConfiguration {
- @Value("${resource_id}")
- private String resourceId;
-
- @Configuration
- @EnableAuthorizationServer
- protected class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
-
- @Autowired
- private AuthenticationManager authenticationManager;
-
- @Autowired
- private UserDetailsService userDetailsService;
-
- @Autowired
- private PasswordEncoder passwordEncoder;
-
- @Autowired
- private TokenStore tokenStore;
-
- @Autowired
- private SecurityConfiguration securityConfiguration;
-
- @Override
- public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
- security.passwordEncoder(passwordEncoder);
- }
-
- @Override
- public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
- clients.inMemory()
- .withClient(securityConfiguration.getMobileAppClientId())
- .authorizedGrantTypes("password", "refresh_token")
- .scopes("mobile_app")
- .resourceIds(resourceId)
- .secret(passwordEncoder.encode(securityConfiguration.getMobileAppClientSecret()));
- }
-
- @Override
- public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
- endpoints.tokenStore(tokenStore)
- .authenticationManager(authenticationManager)
- .userDetailsService(userDetailsService);
- }
- }
-
- @Configuration
- @EnableResourceServer
- @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
- protected class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
-
- @Override
- public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
- resources.resourceId(resourceId);
- }
-
- @Override
- public void configure(HttpSecurity http) throws Exception {
- http.authorizeRequests()
- .antMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()
- .and()
- .antMatcher("/api/**").authorizeRequests()
- .antMatchers(HttpMethod.POST, "/api/users").permitAll()
- .anyRequest().authenticated();
- }
- }
- }
对测试代码重构
现在只有一个Controller,对应也只创建了一个Controller的测试代码,如果后面再有新的controller,也要创建新的controller测试代码,就会有一些重复代码。为了减少重复代码,下面对controller的测试代码做一点重构工作。
创建自己的注解
在控制器测试代码上有一组注解:
- @WebMvcTest(UserRestController.class)
- @ActiveProfiles(SpringProfiles.TEST)
这两行注解基本上所有的控制器测试类都要有。可以合并成自定义注解,用一行代码实现两行代码的效果。
BabyControllerTest .java
- package com.babyrabbit.demo.util;
-
- import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
- import org.springframework.core.annotation.AliasFor;
- import org.springframework.test.context.ActiveProfiles;
- import org.springframework.test.context.ContextConfiguration;
-
- import java.lang.annotation.Retention;
- import java.lang.annotation.RetentionPolicy;
-
- @Retention(RetentionPolicy.RUNTIME)
- @WebMvcTest
- @ContextConfiguration(classes = BabyControllerTestConfiguration.class)
- @ActiveProfiles(SpringProfiles.TEST)
- public @interface BabyControllerTest {
- @AliasFor(annotation = WebMvcTest.class, attribute = "value")
- Class<?>[] value() default {};
-
- @AliasFor(annotation = WebMvcTest.class, attribute = "controllers")
- Class<?>[] controllers() default {};
- }
这个注解是利用spring的meta-annotations特性,将多个注解组合成一个注解。
BabyControllerTestConfiguration.java
- package com.babyrabbit.demo.util;
-
- import com.babyrabbit.demo.conf.OAuth2ServerConfiguration;
- import com.babyrabbit.demo.conf.SecurityConfiguration;
- import com.babyrabbit.demo.web.StubUserDetailsService;
- import org.springframework.boot.test.context.TestConfiguration;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Import;
- import org.springframework.security.core.userdetails.UserDetailsService;
- import org.springframework.security.oauth2.provider.token.TokenStore;
- import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
-
- @TestConfiguration
- @Import(OAuth2ServerConfiguration.class)
- public class BabyControllerTestConfiguration {
- @Bean
- public UserDetailsService userDetailsService() {
- return new StubUserDetailsService();
- }
- @Bean
- public TokenStore tokenStore() {
- return new InMemoryTokenStore();
- }
- @Bean
- public SecurityConfiguration securityConfiguration() {
- return new SecurityConfiguration();
- }
- }
-
这个类就是前面在UserRestControllerTest.java中的那个静态类TestConfig,把这个类拿出来放到单独的文件里,以便在多个ControllerTest中重用。
这样,UserRestControllerTest中就需要把TestConfig静态类移除:
UserRestControllerTest.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.conf.OAuth2ServerConfiguration;
- import com.babyrabbit.demo.conf.SecurityConfiguration;
- import com.babyrabbit.demo.user.UserService;
- import com.babyrabbit.demo.user.Users;
- import com.babyrabbit.demo.util.BabyControllerTest;
- import com.babyrabbit.demo.util.SpringProfiles;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
- import org.springframework.boot.test.mock.mockito.MockBean;
- import org.springframework.http.MediaType;
- import org.springframework.test.context.ActiveProfiles;
- import org.springframework.test.context.junit4.SpringRunner;
- import org.springframework.test.web.servlet.MockMvc;
-
- import java.util.Optional;
-
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.HEADER_AUTHORIZATION;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.bearer;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.obtainAccessToken;
- import static org.mockito.Mockito.verify;
- import static org.mockito.Mockito.when;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
- @RunWith(SpringRunner.class)
- @BabyControllerTest(UserRestController.class)
- public class UserRestControllerTest {
- @Autowired
- private MockMvc mvc;
- @MockBean
- private UserService service;
-
- @Autowired
- private ObjectMapper objectMapper;
-
- @Test
- public void givenNotAuthenticated_whenAskingMyDetails_forbidden() throws Exception {
- mvc.perform(get("/api/users/me")).andExpect(status().isUnauthorized());
- }
-
- @Test
- public void givenAuthenticatedAsOfficer_whenAskingMyDetails_detailsReturned() throws Exception {
- String accessToken = obtainAccessToken(mvc, Users.OFFICER_EMAIL, Users.OFFICER_PASSWORD);
-
- when(service.getUser(Users.officer().getId())).thenReturn(Optional.of(Users.officer()));
-
- mvc.perform(get("/api/users/me")
- .header(HEADER_AUTHORIZATION, bearer(accessToken)))
- .andExpect(status().isOk())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("email").value(Users.OFFICER_EMAIL))
- .andExpect(jsonPath("roles").isArray())
- .andExpect(jsonPath("roles[0]").value("OFFICER"))
- ;
- }
-
- @Test
- public void testCreateOfficer() throws Exception {
- String email = "wim.deblauwe@example.com";
- String password = "my-super-secret-pwd";
-
- CreateOfficerParameters parameters = new CreateOfficerParameters();
- parameters.setEmail(email);
- parameters.setPassword(password);
-
- when(service.createOfficer(email, password)).thenReturn(Users.newOfficer(email,password));
-
- mvc.perform(post("/api/users")
- .contentType(MediaType.APPLICATION_JSON_UTF8)
- .content(objectMapper.writeValueAsString(parameters)))
- .andExpect(status().isCreated())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("email").value(email))
- .andExpect(jsonPath("roles").isArray())
- .andExpect(jsonPath("roles[0]").value("OFFICER"));
-
- verify(service).createOfficer(email, password);
- }
- }
使用MySQL数据库
前面的代码,都是在内存里模拟用户数据,实际项目里,还是会需要用到数据库的。这里就用MySQL数据库来继续开发这个工程。
配置数据库信息
由于我打算将数据库和API服务放在一个服务器上,将使用spring profile配合来配置数据库连接信息。
设定访问localhost 数据库的profile为 local,首先需要创建application-local.properties,配置数据库访问信息:
- copsboot-security.mobile-app-client-id=baby-mobile-client
- copsboot-security.mobile-app-client-secret=ccUyb6vS4S8nxfbKPCrN
-
- spring.datasource.url=jdbc:mysql://localhost/babydb
- spring.datasource.driverClassName=com.mysql.jdbc.Driver
- spring.datasource.username=dbadmin
- spring.datasource.password=dbadmin
- spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
- spring.jpa.hibernate.ddl-auto=none
这个文件中包含了敏感信息,所以不要把这个文件提交到git上。推荐个好的实践,提交application-local.properties.template作为样例文件,当然,要把其中的敏感信息隐藏掉
创建数据库
为了对数据库变更也能做版本管理,spring boot 支持两个库: Flyway 和 Liquibase,在spring boot启动时,自动执行SQL 脚本确保数据库是最新的。
这两个库最大的区别是:Flyway 使用的是SQL脚本,而Liquibase使用的是XML语法。这里我使用的是Flyway。首先需要在工程的pom.xml中添加依赖:
- <dependency>
- <groupId>org.flywaydb</groupId>
- <artifactId>flyway-core</artifactId>
- </dependency>
在src/main/resources下创建db/migration目录作为Flyway的工作目录。在migration目录里,再创建一个h2目录,一个mysql目录,分别用来放数据库脚本。
实际的目录结构如下:
- pom.xml
- mvnw
- mvnw.cmd
- src
- |-- main
- |-- java
- |-- com.babyrabbit.demo
- |-- DemoApplication
- |-- resources
- |-- application.properties
- |-- db
- |-- migration
- |-- h2
- |-- V1.0.0.1__authentication.sql
- |-- mysql
- |-- V1.0.0.1__authentication.sql
- |-- V1.0.0.2__users.sql
- |-- test
- |-- java
- |-- com.babyrabbit.demo
- |-- ApplicationTests
sql文件的名字非常重要,Flyway将使用这个名字来决定这些脚本的执行顺序。
创建数据表的脚本可以自己手写,可以让jpa自动生成创建脚本:
application-local.properties
- spring.jpa.properties.javax.persistence.schema-generation.create-source=metadata
- spring.jpa.properties.javax.persistence.schema-generation.scripts.action=create
- spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=create.sql
当选用local作为激活的profile,启动程序,spring boot会自动创建create.sql文件,包含所有的创建脚本。
把create.sql 脚本复制到mysql目录下,命名为V1.0.0.2_users.sql , 在application-local.properties
添加配置,告诉Flpyway 脚本的相对位置:
spring.flyway.locations=classpath:db/migration/mysql
对于dev 这个profile,需要配置的是application-dev.properties
:
- spring.flyway.locations=classpath:db/migration/h2
- spring.jpa.hibernate.ddl-auto=create-drop
初始化数据库
当使用dev这个profile 启动服务时, 会触发spring boot执行DevelopmentDbInitializer, 创建测试用户。如果想同时在MySQL中创建默认用户,可以在启动时候,同时使用 dev, local作为激活的profile。 激活的profile的顺序很重要,dev在前, local在后,这样local可以重写数据库连接属性。
Flyway将在库里创建一个schema_version表,用来记录那些更新已经执行,那些还没有执行。
注意: 一旦发布了一个数据库更新脚本,并让flyway执行了,就不要更改这个文件了,否则会导致flyway拒绝执行任何脚本,最终导致程序启动失败。如果要修改数据库,就新创建一个脚本,不要在已有脚本中修改。
校验
spring中可以使用javax.validation.constrants
包里的校验注解,也可以使用org.hibernate.validation.constrants
包里校验注解。
这些校验注解都是用来校验对象里的字段的。在controller中,启用一个校验,使用@Valid 注解,则框架会根据数据对象里的校验注解对数据进行校验。
使用exception handler处理校验错误
例外处理如果跟正常业务逻辑混在一起,不但让代码乱,有很多重复代码,还可能引入bug。spring提供一个@ExceptionHandler注解,来单独创建例外处理,这样就可以把例外处理的代码集中放置。
要处理Controller中抛出的例外,需要使用@ControllerAdvice注解,捕捉Controller中的例外:
RestControllerExceptionHandler.java
- package com.babyrabbit.demo.web;
-
- import org.springframework.http.HttpStatus;
- import org.springframework.web.bind.MethodArgumentNotValidException;
- import org.springframework.web.bind.annotation.ControllerAdvice;
- import org.springframework.web.bind.annotation.ExceptionHandler;
- import org.springframework.web.bind.annotation.ResponseBody;
- import org.springframework.web.bind.annotation.ResponseStatus;
-
- import java.util.Collections;
- import java.util.List;
- import java.util.Map;
- import java.util.stream.Collectors;
-
- @ControllerAdvice
- public class RestControllerExceptionHandler {
- @ExceptionHandler
- @ResponseBody
- @ResponseStatus(HttpStatus.BAD_REQUEST)
- public Map<String, List<FieldErrorResponse>> handle(MethodArgumentNotValidException exception) {
- return error(exception.getBindingResult()
- .getFieldErrors()
- .stream()
- .map(fieldError -> new FieldErrorResponse(fieldError.getField(),
- fieldError.getDefaultMessage()))
- .collect(Collectors.toList()));
- }
- private Map<String, List<FieldErrorResponse>> error(List<FieldErrorResponse> errors)
- {
- return Collections.singletonMap("errors", errors);
- }
- }
对上面代码的一点解释:
- @ControllerAdvice注解标注这个类的代码是应用到这个工程的所有controller上。
- @ExceptionHandler注解标注当controller抛出例外时,调用这个方法
上面代码中,用到了一个数据容器,当校验失败时,用其保持校验失败的字段名及校验失败的错误信息:
FieldErrorResponse.java
- package com.babyrabbit.demo.web;
-
- import lombok.Value;
-
- @Value
- public class FieldErrorResponse {
- private String fieldName;
- private String errorMessage;
- }
自定义字段校验器
为了演示如何使用自定义字段校验器,下面创建了Report模块。
ReportRestController.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.report.ReportService;
- import com.babyrabbit.demo.security.ApplicationUserDetails;
- import org.springframework.http.HttpStatus;
- import org.springframework.security.core.annotation.AuthenticationPrincipal;
- import org.springframework.web.bind.annotation.*;
-
- import javax.validation.Valid;
-
- @RestController
- @RequestMapping("/api/reports")
- public class ReportRestController {
-
- private final ReportService service;
-
- public ReportRestController(ReportService service) {
- this.service = service;
- }
-
- @PostMapping
- @ResponseStatus(HttpStatus.CREATED)
- public ReportDto createReport(@AuthenticationPrincipal ApplicationUserDetails userDetails,
- @Valid @RequestBody CreateReportParameters parameters) {
- return ReportDto.fromReport(service.createReport(userDetails.getUserId(),
- parameters.getDateTime(),
- parameters.getDescription()));
- }
- }
Report.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.orm.jpa.AbstractEntity;
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.util.ArtifactForFramework;
- import lombok.Getter;
-
- import javax.persistence.Entity;
- import javax.persistence.ManyToOne;
- import java.time.ZonedDateTime;
-
- @Getter
- @Entity
- public class Report extends AbstractEntity<ReportId> {
- @ManyToOne
- private User reporter;
- private ZonedDateTime dateTime;
- private String description;
-
- @ArtifactForFramework
- protected Report() {
- }
-
- public Report(ReportId id, User reporter, ZonedDateTime dateTime, String description)
- {
- super(id);
- this.reporter = reporter;
- this.dateTime = dateTime;
- this.description = description;
- }
- }
ReportId.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.orm.jpa.AbstractEntityId;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public class ReportId extends AbstractEntityId<UUID> {
- protected ReportId(){
-
- }
-
- public ReportId(UUID id){
- super(id);
- }
-
- @Override
- public String asString() {
- return super.getId().toString();
- }
- }
ReportRepository.java
- package com.babyrabbit.demo.report;
-
- import org.springframework.data.repository.CrudRepository;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public interface ReportRepository extends CrudRepository<Report, UUID>,ReportRepositoryCustom{
- }
-
ReportRepositoryCustomer.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.user.UserId;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public interface ReportRepositoryCustom {
- ReportId nextId();
- }
-
ReportRepositoryImpl.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.orm.jpa.UniqueIdGenerator;
-
- import java.util.UUID;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public class ReportRepositoryImpl implements ReportRepositoryCustom{
- private final UniqueIdGenerator<UUID> generator;
- public ReportRepositoryImpl(UniqueIdGenerator<UUID> generator) {
- this.generator = generator;
- }
- @Override
- public ReportId nextId() {
- return new ReportId(generator.getNextUniqueId());
- }
- }
-
ReportService.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.user.UserId;
-
- import java.time.ZonedDateTime;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public interface ReportService {
- Report createReport(UserId userId, ZonedDateTime dateTime, String description);
- }
-
ReportServiceImpl.java
- package com.babyrabbit.demo.report;
-
- import com.babyrabbit.demo.user.User;
- import com.babyrabbit.demo.user.UserId;
- import com.babyrabbit.demo.user.UserNotFoundException;
- import com.babyrabbit.demo.user.UserRepository;
- import org.springframework.beans.factory.annotation.Autowired;
-
- import java.time.ZonedDateTime;
-
- /**
- * Created by kevin on 2018/9/14.
- */
- public class ReportServiceImpl implements ReportService{
-
- private final ReportRepository reportRepository;
- private final UserRepository userRepository;
- @Autowired
- public ReportServiceImpl(ReportRepository reportRepository, UserRepository userRepository){
- this.reportRepository = reportRepository;
- this.userRepository = userRepository;
- }
-
- @Override
- public Report createReport(UserId userId, ZonedDateTime dateTime, String description) {
- User user = userRepository.findById(userId.getId()).orElseThrow(() -> new UserNotFoundException(userId));
- Report report = Reports.createReport(reportRepository.nextId(), user,dateTime,description);
- return reportRepository.save(report);
- }
- }
-
ReportDto.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.report.Report;
- import com.babyrabbit.demo.report.ReportId;
- import lombok.Value;
-
- import java.time.ZonedDateTime;
-
- @Value
- public class ReportDto {
- private ReportId id;
- private String reporter;
- private ZonedDateTime dateTime;
- private String description;
-
- public static ReportDto fromReport(Report report) {
- return new ReportDto(report.getId(),
- report.getReporter().getEmail(),
- report.getDateTime(),
- report.getDescription());
- }
- }
最后为上面的代码创建测试代码:
ReportRestControllerTest.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.report.Report;
- import com.babyrabbit.demo.report.ReportId;
- import com.babyrabbit.demo.report.ReportService;
- import com.babyrabbit.demo.user.Users;
- import com.babyrabbit.demo.util.BabyControllerTest;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import org.junit.Test;
- import org.junit.runner.RunWith;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.mock.mockito.MockBean;
- import org.springframework.http.MediaType;
- import org.springframework.test.context.junit4.SpringRunner;
- import org.springframework.test.web.servlet.MockMvc;
-
- import java.time.ZonedDateTime;
- import java.util.UUID;
-
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.HEADER_AUTHORIZATION;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.bearer;
- import static com.babyrabbit.demo.security.SecurityHelperForMockMvc.obtainAccessToken;
- import static org.mockito.ArgumentMatchers.any;
- import static org.mockito.ArgumentMatchers.eq;
- import static org.mockito.Mockito.when;
- import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
- @RunWith(SpringRunner.class)
- @BabyControllerTest(ReportRestController.class)
- public class ReportRestControllerTest {
- @Autowired
- private MockMvc mvc;
- @Autowired
- private ObjectMapper objectMapper;
- @MockBean
- private ReportService service;
- @Test
- public void officerIsAbleToPostAReport() throws Exception {
- String accessToken = obtainAccessToken(mvc, Users.OFFICER_EMAIL, Users
- .OFFICER_PASSWORD);
- ZonedDateTime dateTime = ZonedDateTime.parse("2018-04-11T22:59:03.189+02:00");
- String description = "This is a test report description.";
- CreateReportParameters parameters = new CreateReportParameters(dateTime,
- description);
- when(service.createReport(eq(Users.officer().getId()), any(ZonedDateTime.class),
- eq(description)))
- .thenReturn(new Report(new ReportId(UUID.randomUUID()), Users.officer(),
- dateTime, description));
- mvc.perform(post("/api/reports")
- .header(HEADER_AUTHORIZATION, bearer(accessToken))
- .contentType(MediaType.APPLICATION_JSON_UTF8)
- .content(objectMapper.writeValueAsString(parameters)))
- .andExpect(status().isCreated())
- .andExpect(jsonPath("id").exists())
- .andExpect(jsonPath("reporter").value(Users.OFFICER_EMAIL))
- .andExpect(jsonPath("dateTime").value("2018-04-11T22:59:03.189+02:00"))
- .andExpect(jsonPath("description").value(description));
- }
- }
-
下面进入正题, 针对description字段,校验内容中是否有敏感词。这个校验需要自定义一个校验器ValidReportDescription:
ValidReportDescription.java
- package com.babyrabbit.demo.web;
-
- import javax.validation.Constraint;
- import javax.validation.Payload;
- import java.lang.annotation.ElementType;
- import java.lang.annotation.Retention;
- import java.lang.annotation.RetentionPolicy;
- import java.lang.annotation.Target;
-
- @Target(ElementType.FIELD)
- @Retention(RetentionPolicy.RUNTIME)
- @Constraint(validatedBy = {ReportDescriptionValidator.class})
- public @interface ValidReportDescription {
- String message() default "Invalid report description";
- Class<?>[] groups() default {};
- Class<? extends Payload>[] payload() default {};
- }
这个注解用来在description字段上添加校验标记。
ReportDescriptionValidator.java
- package com.babyrabbit.demo.web;
-
- import javax.validation.ConstraintValidator;
- import javax.validation.ConstraintValidatorContext;
-
- public class ReportDescriptionValidator implements ConstraintValidator<ValidReportDescription, String> {
- @Override
- public void initialize(ValidReportDescription constraintAnnotation) {
- }
-
- @Override
- public boolean isValid(String value, ConstraintValidatorContext context) {
- boolean result = true;
- if (!value.toLowerCase().contains("suspect")) {
- result = false;
- }
- return result;
- }
- }
这个类实现了真正的校验逻辑。
下面,就可以将这个自定义的字段校验器应用到代码中:
CreateReportParameters.java
- package com.babyrabbit.demo.web;
-
- import lombok.AllArgsConstructor;
- import lombok.Data;
- import lombok.NoArgsConstructor;
-
- import java.time.ZonedDateTime;
-
- @Data
- @AllArgsConstructor
- @NoArgsConstructor
- public class CreateReportParameters {
- private ZonedDateTime dateTime;
-
- @ValidReportDescription
- private String description;
- }
将自定义对象校验器注册玮Spring服务
普通的校验注解只是做前端校验,并没有深入到后端数据库或业务层面上的校验。如果创建一个User的时候,需要深入到数据库查重,这时候需要调用UserService来访问数据,执行业务逻辑。UserService是Spring管理的Service,因此要求校验器也要被Spring管理,才能实现注入。
下面先创建校验器注解类:
ValidCreateUserParameters.java
- package com.babyrabbit.demo.web;
-
- import javax.validation.Constraint;
- import javax.validation.Payload;
- import java.lang.annotation.ElementType;
- import java.lang.annotation.Retention;
- import java.lang.annotation.RetentionPolicy;
- import java.lang.annotation.Target;
-
- @Target(ElementType.TYPE)
- @Retention(RetentionPolicy.RUNTIME)
- @Constraint(validatedBy = {CreateUserParametersValidator.class})
- public @interface ValidCreateUserParameters {
- String message() default "Invalid user";
- Class<?>[] groups() default {};
- Class<? extends Payload>[] payload() default {};
- }
CreateUserParametersValidator 是校验逻辑实现类。
CreateUserParametersValidator.java
- package com.babyrabbit.demo.web;
-
- import com.babyrabbit.demo.user.UserService;
- import org.springframework.beans.factory.annotation.Autowired;
-
- import javax.validation.ConstraintValidator;
- import javax.validation.ConstraintValidatorContext;
-
- public class CreateUserParametersValidator implements ConstraintValidator<ValidCreateUserParameters, CreateOfficerParameters> {
- private final UserService userService;
-
- @Autowired
- public CreateUserParametersValidator(UserService userService) {
- this.userService = userService;
- }
-
- @Override
- public void initialize(ValidCreateUserParameters constraintAnnotation) {
- }
-
- @Override
- public boolean isValid(CreateOfficerParameters userParameters,ConstraintValidatorContext context) {
- boolean result = true;
- if (userService.findUserByEmail(userParameters.getEmail()).isPresent()) {
- context.buildConstraintViolationWithTemplate(
- "There is already a user with the given email address.")
- .addPropertyNode("email").addConstraintViolation();
- result = false;
- }
- return result;
- }
- }
在上面的代码中,注入了UserService。
这样就实现了调用Spring Service做逻辑校验。
OAuth Database Schema
MySQL
- CREATE TABLE oauth_client_details (
- client_id VARCHAR(255) PRIMARY KEY,
- resource_ids VARCHAR(255),
- client_secret VARCHAR(255),
- scope VARCHAR(255),
- authorized_grant_types VARCHAR(255),
- web_server_redirect_uri VARCHAR(255),
- authorities VARCHAR(255),
- access_token_validity INTEGER,
- refresh_token_validity INTEGER,
- additional_information VARCHAR(4096),
- autoapprove TINYINT
- );
-
- CREATE TABLE oauth_client_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication_id VARCHAR(255) PRIMARY KEY,
- user_name VARCHAR(255),
- client_id VARCHAR(255)
- );
-
- CREATE TABLE oauth_access_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication_id VARCHAR(255) PRIMARY KEY,
- user_name VARCHAR(255),
- client_id VARCHAR(255),
- authentication BLOB,
- refresh_token VARCHAR(255)
- );
-
- CREATE TABLE oauth_refresh_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication BLOB
- );
-
- CREATE TABLE oauth_code (
- activationCode VARCHAR(255),
- authentication BLOB
- );
H2
- CREATE TABLE oauth_client_details (
- client_id VARCHAR(255) PRIMARY KEY,
- resource_ids VARCHAR(255),
- client_secret VARCHAR(255),
- scope VARCHAR(255),
- authorized_grant_types VARCHAR(255),
- web_server_redirect_uri VARCHAR(255),
- authorities VARCHAR(255),
- access_token_validity INTEGER,
- refresh_token_validity INTEGER,
- additional_information VARCHAR(4096),
- autoapprove VARCHAR(255)
- );
-
- CREATE TABLE oauth_client_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication_id VARCHAR(255) PRIMARY KEY,
- user_name VARCHAR(255),
- client_id VARCHAR(255)
- );
-
- CREATE TABLE oauth_access_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication_id VARCHAR(255) PRIMARY KEY,
- user_name VARCHAR(255),
- client_id VARCHAR(255),
- authentication BLOB,
- refresh_token VARCHAR(255)
- );
-
- CREATE TABLE oauth_refresh_token (
- token_id VARCHAR(255),
- token BLOB,
- authentication BLOB
- );
-
- CREATE TABLE oauth_code (
- activationCode VARCHAR(255),
- authentication BLOB
- );