分类
Spring Security

Spring Security 表达式 – hasRole 简介

1. 概述

Spring Security使用强大的Spring Expreession Language(SpEL)提供了多种表达式。这些表达式大多都是围绕应用上下文(当前的登录用户)实现的。

Spring Security表达式均是由SecurityExpressionRoot实现的,它是web安全以及在方法上加入验证的基础。

Spring Security 3.0的授权机制中开始使用了SpEL表达式,Spring Security 4.x中沿用了这一机制。你可以在本文中找到关于Spring Security表达式的更多内容。

2. 网站授权(Web Authorization)

Spring Security提供了两个web授权方法:基于URL对整个页面进行授权以及基于安全规则对某个页面的部分内容进行授权。

2.1 对整个页面授权

使用JAVA的配置方式如下:

@Configuration
@EnableWebSecurity
public class SecSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          .authorizeRequests()
          .antMatchers("/admin/**").hasRole("ADMIN");
    }
    ...
}

注意:Spring Security将为hasRole中的ADMIN自动添加ROLE_前缀。

当用户访问的URL匹配到/admin/**hasRole表达式将校验当前登录用户是否拥有ROLE_ADMIN角色。

2.2 对页面中的部分进行授权

第二种授权方式是基于安全表达式对页面的部分进行授权。

2.2.1 JSP

如果使用的JSP技术,则该功能需要以下 Spring Security JSP taglib依赖的支持:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>5.0.5.RELEASE</version>
</dependency>

使用以下代码来启用taglib支持:

<%@ taglib prefix="security"
  uri="http://www.springframework.org/security/tags" %>

此时便可以在当前页面中使用hasRole表达式了。以下代码展示了:当认证用户拥有ROLE_USER时,将显示第一段文字;拥有ROLE_ADMIN时,将显示第二段文字:

<security:authorize access="hasRole('ROLE_USER')">
    This text is only visible to a user
    <br/>
</security:authorize>
<security:authorize access="hasRole('ROLE_ADMIN')">
    This text is only visible to an admin
    <br/>
</security:authorize>

由于笔者没有使用JSP技术的相关经验,所以以上代码未验证。同时,笔者也没有能力在github仓库中提供相应的JSP示例。

2.2.2 thymeleaf

thymeleaf启用页面部分认证需要加入以下依赖:

		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>

在模板中的使用方法如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>hasRole在Thymeleaf中的应用</title>
</head>
<body>
<h2>Welcome</h2>
<p>hasRole在Thymeleaf中的应用</p>
<div sec:authorize="hasRole('USER')">user 角色能够查看到此信息</div>
<div sec:authorize="hasRole('ADMIN')">admin 用户能够看到此信息.</div>
<div sec:authorize="isAuthenticated()">
    登录用户能看到此信息
</div>
当前登录的用户是:
<div sec:authentication="name"></div>
如未登录,可以通过访问/admin/test来模拟登录,如想变更登录用户需要重新打开浏览器并重新启动后台。
</body>
</html>

上述代码在模板中应用了hasRole表达式,达到了特定的用户显示特定的信息的目的。

3. 方法级别的授权 - @PreAuthorize

能过特定的注解,Security表达式能够对特定的方法进行授权

@PreAuthorize以及@PostAuthorize (还包含@PreFilter 和 @PostFilter) 注解支持Spring Expression Language(SpEL)并实现了认证授权。

预使上述注解生效,需要使用@EnableGlobalMethodSecurity对Spring Security进行如下配置:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

XML配置如下:

<global-method-security pre-post-annotations="enabled" />

接下来,便可以在方法中使用 @PreAuthorize 注解了:

@Service
public class FooService {
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    public List<Foo> findAll() { ... }
    ...
}

如此以来,只有拥有ROLE_ADMIN的登录用户才能够成功的访问findAll方法。

注意:@PreAuthorize 以及 @PostAuthorize 是基于代理机制生效的,这意味着使用该注解的方法不能够被声明为final,并且其类型必须为public。

4. 在代码中校验角色

可以在JAVA代码直接对当前登录用户的角色进行判断:

@RequestMapping
public boolean someControllerMethod(HttpServletRequest request) {
    return request.isUserInRole("ROLE_ADMIN");
}

上述代码实现了将接收到一个当前登录用户是否拥有ROLE_ADMIN角色的boolean值。

5. 总结

本文对hasRole表达式的几种使用方法进行了介绍,你学会了吗?

分类
Spring Security

详解Spring Security表达式

1. 简介

本文我们将与大家交流Spring Security表达式并给出相应的使用示例。

在学习如ACL等复杂的表达式以前,打下良好的Spring Security表达式的基础是非常有必要的!

本文是对Spring Security 表达式 – hasRole 简介的补充与延伸。

2. Maven依赖

使用Spring Security,需要添加以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
        <version>5.2.3.RELEASE</version>
    </dependency>
</dependencies>

注意:在Spring Boot中使用时,可以忽略指定版本号。

你可以点击此处获取最新的版本。

如果你没有使用Spring Boot,那么还需要手动添加以下spring-core 以及 spring-context 两个依赖;如果你使用了Spring Boot,Spring Boot将自动我们处理好这一切。

3. 配置

新建一个类并继承WebSecurityConfigurerAdapter:

@Configuration
@EnableAutoConfiguration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityJavaConfig extends WebSecurityConfigurerAdapter {
    ...
}

如果你使用的是XML进行配置,则参考以下代码:

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans ...>
    <global-method-security pre-post-annotations="enabled"/>
</beans:beans>

4. Web 安全表达式(Web Security Expressions)

Sprring Security内置了以下安全表达式:

  • hasRolehasAnyRole
  • hasAuthorityhasAnyAuthority
  • permitAlldenyAll
  • isAnonymousisRememberMeisAuthenticatedisFullyAuthenticated
  • principalauthentication
  • hasPermission

在正式介绍上述表达式以前,让我们建立两个测试用户:user以及admin:

public class SecurityJavaConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user").password(passwordEncoder().encode("user"))
            .authorities("ROLE_USER")
            .and().withUser("admin").password(passwordEncoder().encode("admin"))
            .authorities("ROLE_ADMIN");
    }
}

当然了,你也可以使用XML来配置以上信息:

<authentication-manager>
    <authentication-provider>
        <user-service>
            <user name="user" password="user" authorities="ROLE_USER"/>
            <user name="admin" password="admin" authorities="ROLE_ADMIN"/>
        </user-service>
    </authentication-provider>
</authentication-manager>
<bean name="passwordEncoder" 
  class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

需要注意的是自Spring 5开始,需要为Spring Seuirty提从一个 PasswordEncoder 类型的bean(用于密码加密以及验证密码是否正确)。

接下来,依次对各个表达式进行介绍。

4.1 hasRole, hasAnyRole

我们可以使用上述表达式来对访问某些符合一定规则的URL授权。

比如:

@Override
protected void configure(final HttpSecurity http) throws Exception {
    ...
    .antMatchers("/auth/admin/*").hasRole("ADMIN")
    .antMatchers("/auth/*").hasAnyRole("ADMIN","USER")
    ...
}

以上代码实现了:当前登录用户访问任意以/auth/admin打头的地址时,必须拥有ADMIN角色;当当登录用户访问任意以/auth/ 打头的地址时,最少拥有USER或ADMIN角色的其中一个。

使用XML配置如下:

<http>
    <intercept-url pattern="/auth/admin/*" access="hasRole('ADMIN')"/>
    <intercept-url pattern="/auth/*" access="hasAnyRole('ADMIN','USER')"/>
</http>

4.2 hasAuthority, hasAnyAuthority

Spring Security中的Roles和authorities其实差不多。

它们两个唯一的不同便是:使用Roles的时候,系统会自动为其添加ROLE_前缀(自Spring Security 4版本以后)。

所以hasAuthority(‘ROLE_ADMIN') 与 hasRole(‘ADMIN') 是等价的。

@Override
protected void configure(final HttpSecurity http) throws Exception {
    ...
    .antMatchers("/auth/admin/*").hasAuthority("ROLE_ADMIN")
    .antMatchers("/auth/*").hasAnyAuthority("ROLE_ADMIN", "ROLE_USER")
    ...
}

上述两处代码中,我们完全忽略了ROLE_前缀,这么写是完全没有问题的。

以及:

<http>
    <intercept-url pattern="/auth/admin/*" access="hasAuthority('ROLE_ADMIN')"/>
    <intercept-url pattern="/auth/*" access="hasAnyAuthority('ROLE_ADMIN','ROLE_USER')"/>
</http>

同时authorities表达式与roles表达式的区别也仅限于是否自动添加ROLE_前缀,所以初始测试用户的代码还可以这样写:

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user").password(passwordEncoder().encode("user"))
            .authorities("ROLE_USER")
            .and().withUser("admin").password(passwordEncoder().encode("admin"))
            .authorities("ROLE_ADMIN");
    }

4.3. permitAll, denyAll

这两个表达式也非常的容易理解。它们可以规定禁止/允许任何用户访问某些URL。

比如:

...
.antMatchers("/*").permitAll()
...

上述代码的设置了允许任意用户(无论是登录用户还是未登录的匿名用户)访问以/打头的地址(比如首页)

同样的还可以设置禁止任何人访问系统设置(systemConfig):

...
.antMatchers("/systemConfig").denyAll()
...

使用XML配置的话,示例配置如下:

<http auto-config="true" use-expressions="true">
    <intercept-url access="permitAll" pattern="/*" /> 
    <intercept-url access="denyAll" pattern="/systemConfig" /> 
</http>

4.4. isAnonymous, isRememberMe, isAuthenticated, isFullyAuthenticated

本小节将围绕用户的登录状态展开。当我们想设置:匿名用户(使用匿名信息登录,比如游客1234)的可以访问关于我们(aboutMe)时,使用以下代码:

...
.antMatchers("/aboutMe").anonymous()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/aboutMe" access="isAnonymous()"/>
</http>

如果我们想规定只有登录的用户才能够访问个人中心(personalCenter),则可以使用isAuthenticated() 方法:

...
.antMatchers("/personalCenter").authenticated()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/personalCenter" access="isAuthenticated()"/>
</http>

用户登录时可以通过记住我(RememberMe)以用使用用户名、密码登录两种方式。记住我的登录依赖为cookies,使用这种登录方式避免了每次登录都要输入的用户名、密码。如果你想了解更多关于记往我的登录方式,请点击这里

如果我们想规定允许通过记住我的方式登录站点的用户访问我的信息(message)时,则可以如下用如下代码:

...
.antMatchers("/message").rememberMe()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/message" access="isRememberMe()"/>
</http>

还有一种场景:当用户使用一些特殊敏感的服务时,即使用户当前的登录状态是已登录(使用记住我的方式),我们仍然规定用户必须重新输入用户名、密码等登录信息重新登录一次。

 isFullyAuthenticated()方法便是为解决上述需要而存在的:

...
.antMatchers("/balance").fullyAuthenticated()
...

上述代码实现了只有通过用户名、密码的形式登录的用户才能够访问当前余额界面(balance) ---- 禁止未登录用户以及通过记住我的方式登录的用户。

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/balance" access="isFullyAuthenticated()"/>
</http>

4.5. principal, authentication

principal、authentication表达式可以获取当前认证(匿名)用户的认证主体信息,还可以获取当前上下文中的认证对象。

比如可以使用principal来获取当前登录用户的email、头像等信息(只有登录用户提供的,都可以获取到)。

可以使用authentication获取完整的认证对象,包含该对象被赋予的授权信息。

Spring Security中获取用户的基本信息一文中对上述表达式进行了更详细的介绍。

4.6 hasPermission 接口

表达式旨在为Spring Security表达式与Spring Security的ACL系统架起一座桥梁,通过hasPermission我们可以自定义一些认证的逻辑。

比如在系统中设置一个查看系统信息的后门,仅仅当输入的token值符合一定的算法时才允许访问,否则不允许访问,则示例代码如下:

    @RequestMapping("systemInfo/{token}")
    @PreAuthorize("hasPermission(#token, 'isCorrect')")
    public String systemInfo(@PathVariable String token) {
        ...
    }

上述代码实现了只有当前用户拥有isEditor权限时,才可以执行此方法。

若使上述代码正常工作,还可以在应用上下文中配置一个PermissionEvaluator

<global-method-security pre-post-annotations="enabled">
    <expression-handler ref="expressionHandler"/>
</global-method-security>
 
<bean id="expressionHandler"
    class="org.springframework.security.access.expression
      .method.DefaultMethodSecurityExpressionHandler">
    <property name="permissionEvaluator" ref="customInterfaceImplementation"/>
</bean>

上述配置信息中customInterfaceImplementation需要实现PermissionEvaluator接口。

使用JAVA配置的话,如下:

@Override
protected MethodSecurityExpressionHandler expressionHandler() {
    DefaultMethodSecurityExpressionHandler expressionHandler = 
      new DefaultMethodSecurityExpressionHandler();
    expressionHandler.setPermissionEvaluator(new CustomInterfaceImplementation());
    return expressionHandler;
}

更多详情请参考GIHHUB的code demo。如果你想获取关于自定义表达式的更多内容的话,我们还为你准备了如何自定义安全表达式一文。

5. 总结

本文中我们对Spring Security中的表达式进行了全面的介绍,结合Spring Boot的使用方法以及相关的测试代码请参考github同步代码。希望能对你有所帮助。

分类
Spring Security

Spring Security在方法上进行权限认证

资源列表:

在正式开始前,可以点击以下链接获取一份与本文相同的初始化代码。

https://github.com/codedemo-club/spring-security-method-security/archive/init.zip

1 概述

Spring Security支持方法级别的权限控制。在此机制上,我们可以在任意层的任意方法上加入权限注解,加入注解的方法将自动被Spring Security保护起来,仅仅允许特定的用户访问,从而还到权限控制的目的。

本文将首先介绍几种权限控制注解的使用方法,接着将介绍如何进行相应的单元测试。

2. 启用方法认证

首先加入security依赖如下:

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

接着新建安全配置类:

@Configuration
@EnableGlobalMethodSecurity(
  prePostEnabled = true, 
  securedEnabled = true, 
  jsr250Enabled = true)
public class MethodSecurityConfig 
  extends GlobalMethodSecurityConfiguration {
}
  • prePostEnabled = true 的作用的是启用Spring Security的  @PreAuthorize 以及 @PostAuthorize 注解。
  • securedEnabled = true 的作用是启用Spring Security的@Secured 注解。
  • jsr250Enabled = true 的作用是启用@RoleAllowed 注解

3. 在方法上设置权限认证

3.1 @Secured 注解

@Secured 注解规定了访问访方法的角色列表,在列表中最少指定一种角色。

比如:

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

@Secured("ROLE_VIEWER") 表示只有拥有ROLE_VIEWER角色的用户,才能够访问getUsername()方法。

再比如:

@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
    return userRoleRepository.isValidUsername(username);
}

@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" }) 表示用户拥有"ROLE_VIEWER", "ROLE_EDITOR" 两个角色中的任意一个角色,均可访问 isValidUsername 方法。

注意:@Secured 注解并不支持Spring表示式语言(SpEL)

3.2 @RoleAllowed 注解

@RoleAllowed 遵守了JSR-250标准,在功能及使用方法上与 @Secured 完全相同。所以3.1上的示例代码完全可以改写为:

@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
    //...
}
    
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
    //...
}

3.3 @PreAuthorize 和 @PostAuthorize 注解

@PreAuthorize 以及 @PostAuthorize 注解均支持SpEL(Spring Express Language)。 

@PreAuthorize 注解用于执行方法前,而@PostAuthorize注解用于执行方法后并且可以影响执行方法的返回值。

比如:

@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
    return getUsername().toUpperCase();
}

@PreAuthorize("hasRole('ROLE_VIEWER')") 相当于@Secured(“ROLE_VIEWER”) 。Spring Security表达式 - hasRole的使用的示例一文中详细对hasRole表达式进行了详解。

同样的,前面出现的 @Secured({“ROLE_VIEWER”,”ROLE_EDITOR”}) 也可以替换为:@PreAuthorize(“hasRole(‘ROLE_VIEWER') or hasRole(‘ROLE_EDITOR')”)

@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
    //...
}

除此以外,我们还可以在方法的参数上使用表达式:

@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
    //...
}

如上代码限制了只有当username的值与当前系统登录用户的用户名相同时,才允许访问该方法。

从语法上讲@PreAuthorize中的表达式作用于@PostAuthorize同样生效。

比如:

@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
    //...
}

稍微不同的是:判断username是否与当前登录用户的username相同的操作被放在了方法执行以后。

另外@PostAuthorize注解还可以获取到方法的返回值,并且可以根据该方法来决定最终的授权结果(是允许访问还是不允许访问):

@PostAuthorize
  ("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

上述代码中,仅当loadUserDetail方法的返回值中的username与当前登录用户的username相同时才被允许访问。

本节中我们介绍了几种简单的Spring表达式(SpEL)的使用方法。在Spring Security自定义安全表达式一文中给出了更详细的讲解。

3.4 @PreFilter 以及 @PostFilter 注解

Spring Security提供了一个@PreFilter 注解来对传入的参数进行过滤

@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
    return usernames.stream().collect(Collectors.joining(";"));
}

当usernames中的子项与当前登录用户的用户名不同时,则保留;当usernames中的子项与当前登录用户的用户名相同时,则移除。比如当前使用用户的用户名为zhangsan,此时usernames的值为{"zhangsan", "lisi", "wangwu"},则经@PreFilter过滤后,实际传入的usernames的值为{"lisi", "wangwu"}

如果执行方法中包含有多个类型为Collection的参数,filterObject 就不太清楚是对哪个Collection参数进行过滤了。此时,便需要加入 filterTarget 属性来指定具体的参数名称:

@PreFilter
  (value = "filterObject != authentication.principal.username",
  filterTarget = "usernames")
public String joinUsernamesAndRoles(
  List<String> usernames, List<String> roles) {
 
    return usernames.stream().collect(Collectors.joining(";")) 
      + ":" + roles.stream().collect(Collectors.joining(";"));
}

同样的我们还可以使用@PostFilter 注解来过返回的Collection进行过滤:

@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
    return userRoleRepository.getAllUsernames();
}

此时 filterObject 代表返回值。如果以来上述代码则实现了:移除掉返回值中与当前登录用户的用户名相同的子项。

你可以访问详解Spring Security @PreFilter以及@PostFilter 一文中来获取更多的信息。

3.5 自定义元注解

如果我们需要在多个方法中使用相同的安全注解,则可以通过创建元注解的方式来提升项目的可维护性。

比如创建以下元注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ROLE_VIEWER')")
public @interface IsViewer {
}

然后可以直接将该注解添加到对应的方法上:

@IsViewer
public String getUsername4() {
    //...
}

在生产项目中,由于元注解分离了业务逻辑与安全框架,所以使用元注解是一个非常不错的选择。

3.6 在类上使用安全注解

如果一个类中的所有的方法我们全部都是应用的同一个安全注解,那么此时则应该把安全注解提升到类的级别上:

@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {
 
    public String getSystemYear(){
        //...
    }
 
    public String getSystemDate(){
        //...
    }
}

上述代码实现了:访问getSystemYear 以及getSystemDate 方法均需要ROLE_ADMIN权限。

3.7 在一个方法上应用多个安全注解

在一个安全注解无法满足我们的需求时,还可以应用多个安全注解:

@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

此时Spring Security将在执行方法前执行@PreAuthorize的安全策略,在执行方法后执行@PostAuthorize的安全策略。

4. 重要提示

在此结合我们的使用经验,给出以下两点提示:

  • 默认情况下,在方法中使用安全注解是由Spring AOP代理实现的,这意味着:如果我们在方法1中去调用同类中的使用安全注解的方法2,则方法2上的安全注解将失效。
  • Spring Security上下文是线程绑定的,这意味着:安全上下文将不会传递给子线程。在Spring Security 上下文传送中我们对此做了更多的介绍。
    public boolean isValidUsername4(String username) {
        // 以下的方法将会跳过安全认证
        this.getUsername();
        return true;
    }

5. 单元测试

5.1 配置

和测试其它的SpringBoot项目相同,新建如下测试类。

@SpringBootTest
public class TestMethodSecurity {
}

如上,在测试类中添加@SpringBootTest注解。然后在pom.xml中添加如下依赖:

		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>

5.2 测试用户名以及角色

比如测试如下方法:

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

上述方法使用了@Secured进行权限认证,在调用该方法时:如果当前登录用户拥有ROLE_VIEWER角色,则将正常执行;否则将抛出AuthenticationCredentialsNotFoundException

在单元测试中,可以使用@WithMockUser快速的设置当前的登录用户。

@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
    
    assertEquals("john", userName);
}

上述代码中我们使用@WithMockUser提供了一个名为john的登录用户,该用户拥有ROLE_VIEWER角色。

注意:在设置测试用户的角色时,必须省略ROLE_前缀。

如果你不喜欢这个默认设置,可以使用authority来代替role

比如定义如下getUsernameInLowerCase方法:

    @PreAuthorize("hasAuthority('SYS_ADMIN')")
    public String getUsernameInLowerCase(){
        return getUsername().toLowerCase();
    }

则在单元测试中同样应该使用全称:

@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
    String username = userRoleService.getUsernameInLowerCase();
 
    assertEquals("john", username);
}

同样的,如果我们希望在某个单元测试的所有方法上都使用同一个登录用户,则可以将 @WithMockUser注解提升到类的展面上

@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class TestWithMockUserAtClassLevel {
    //...
}

如果需要模拟匿名用户,则需要使用 @WithAnonymousUser 注解:

    @Test
    @WithAnonymousUser
    public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
        assertThrows(AccessDeniedException.class, () -> userRoleService.getUsername());
    }

如上代码断言在匿名用户登录的情况下调用userRoleService.getUsername()将得到一个AccessDeniedException

5.3 在单元测试中使用自定义的UserDetailsService

在大多数的项目中,通常会自定义一个认证主体类:新建一个类并实现org.springframework.security.core.userdetails.UserDetails接口。

本文中将新建CustomUser做为认证主体类,设置该类继承Spring的内置org.springframework.security.core.userdetails.Use类,该类实现了UserDetails接口:

public class CustomUser extends User {
    private String nickName;
    // getter and setter
}

然后使用@PostAuthorize 注解举例如下:

@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

上述代码实现了:对返回值中的username与自定义认证主体中的nickName进行权限认证。

可以通过提供一个UserDetailsService的实现来测试上述代码:

@Test
@WithUserDetails(
  value = "john", 
  userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
 
    CustomUser user = userService.loadUserDetail("jane");
 
    assertEquals("jane", user.getNickName());
}

@WithUserDetails注解的userDetailsServiceBeanName属性指定了使用UserDetailsService来初始化认证用户。UserDetailsService可以是一个真实的实现,也可以是一个模拟的实现。

@WithUserDetails注解的value值指当前登录用户的用户名。

和前面两个注解一样,@WithUserDetails注解还可以直接声明在类上表示此类中的所有的方法均使用相同的模拟认证信息。

5.4 测试元注解

假设拥有以下元注解:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer { }

该注解应用于以下方法:

@WithMockJohnViewer
public String getUsername() {
    //...
}

可以在单元测试中使用 @WithMockJohnViewer 模拟当前登录用户:

@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
 
    assertEquals("john", userName);
}

除此以外,也可以使用@WithUserDetails来声明模拟登录用户、角色达到与@WithMockJohnViewer相同的效果。

6 总结

本文中,我们先后介绍了几种Spring Security在方法进行权限认证的方法。

最后将单独的讲解了几个权限认证方法对应的单元测试方法。希望能你能有所帮助。

分类
spring-boot

Spring Boot: 自定义 Whitelabel 错误页面

本文资源:

在正式开始前,可以点击以下链接获取一份与本文相同的初始化代码。

1. 概述

本文将介绍如何禁用以及自定义Spring Boot的默认错误页面。

2. 禁用错误页

可以简单的通过设置以下配置项来达到禁用错误页的目的:

server.error.whitelabel.enabled=false

此时若发生错误则将显示其servlet的错误信息,比如将显示Tomcat的错误页面。

除上述方法外,还可以通过排除ErrorMvcAutoConfiguration bean的方法来达到同样的效果:

spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration

除上述两种方法外,也可以在系统启动类上加入以下注解:

@EnableAutoConfiguration(exclude = {ErrorMvcAutoConfiguration.class})

3. 显示自定义错误页面

在开始自定义错误页面以前,首先确认当前项目加入了Thymeleaf 模拟引擎。在此基础上,新建如下 error.html 模板:

<!DOCTYPE html>
<html>
<body>
<h1>Something went wrong! </h1>
<h2>Our Engineers are on it</h2>
<a href="/">Go Home</a>
</body>
</html>

注意:Thymeleaf 默认的模板位于 resources/templates 目录。该目录下的 error.html 模板将会自动被 Spring Boot 中的BasicErrorController 识别并应用。此时一旦发生错误,Spring Boot将会渲染 error.html 并显示在浏览器上。

除此以外Spring Boot还支持按错语的状态码来自定义错误信息,比如我们想定义发生404错语的信息,则可以在resources/templates/error 中新建404.html文件。

此时,若发生404错误,Spring Boot将渲染404.html.

3.1 自定义错误控制器

仅仅通过自定义错误模板的方法还远远不错。比如:我们想在错误发生时显示更多、更人性的信息;记录发生错误时用户请求信息;在404页面中进行热门商品的推送等。

这时候就需要一个自定义错语控制器:

@Controller
public class CustomErrorController implements ErrorController {

    @RequestMapping("/error")
    public String handleError() {
        System.out.println("hello");
        // 进行一些逻辑处理,比如记错报错时的请求地址、请求参数、登录用户等
        return "error";
    }

    @Override
    public String getErrorPath() {
        return null;
    }
}

如上所示,使用实现ErrorController接口的方法来标识此类为 自定义错误控制器。当发生错误时handleError()将被触发,返回值对应 Thymeleaf 的相应模板,此时可以通过修改error.html以达到自定义提示信息的目的。不仅如此,我们还可以在C层中进行一些逻辑处理,或将一些希望显示在错误错误的信息由C层组装并在模板中渲染。

以下我们演示一下如果简单地对handleError() 进行升级,以使其能够在发生不同的错误时显示不同的错误信息。

@RequestMapping("/error")
public String handleError(HttpServletRequest request) {
    Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    
    if (status != null) {
        Integer statusCode = Integer.valueOf(status.toString());
    
        if(statusCode == HttpStatus.NOT_FOUND.value()) {
            // 发生404错误时,渲染error-404模板
            return "error-404";
        }
        else if(statusCode == HttpStatus.INTERNAL_SERVER_ERROR.value()) {
            // 发生500错误时,渲染error-500模板
            return "error-500";
        }
    }
    // 未获取到错误类型时,渲染error模板
    return "error";
}

此时当发生404错语时,将为用户展示error-404.html模板;当发生500错误,将为用户展示error-500.html模板。

4. 总结

本文展示了几种自定制错误页面的方法,在此方法的基础上当系统发生相应的错误时,可以展示更加友好的提示信息。

分类
REST Series spring

REST with Spring系列教程

REST API基础

分类
Series spring

Spring系列教程

分类
spring

Spring ResponseStatusException教程

分类
REST spring

Spring REST异常处理(自定义错误信息)

本文辅助资源:

在开始以前你可以点击此处以获取一份与本教程相同的初始化代码。

1. 概述

本文将给出几种不同的方法,实现在Spring REST中处理异常的目的。

在Spring 3.2以前,在Spring MVC应用中主要有两种处理异常的方法:使用HandlerExceptionResolver@ExceptionHandler注解

由于上述两个异常处理方法存在诸多缺点,所以在Spring 3.2版本及以后Spring提供了更为优秀的@ControllerAdvice

在当前Spring 5版本中,又引入了一种快速、简单的的异常处理方式:使用ResponseStatusException异常处理类。

上述几种方法均在分层上做的很优秀:我们可以在应用的任意位置抛出异常,该异常最终将被成功的捕获并处理。

下面,我们正式介绍几种解决异常处理的几种方案:

2. 方法一 ─ 作用于控制器的 @ExceptionHandler

第一种解决方案作用于 @Controller级别 ─ 如下代码展示如何@ExceptionHandler注解定义一个异常处理器:

public class FooController{
    
    //...
    @ExceptionHandler({ CustomException1.class, CustomException2.class })
    public void handleException() {
        //
    }
}

该方法有一个显著的缺点:该异常处理器仅在当前的控制器中生效。当然了,我们可以将此代码依次添加到每个控制器中(这明显违反了不造同一个轮子的原则)。

或者我们可以建立一个根控制器,在此控制器中添加上述异常处理代码,然后让所有的控制器都继承此根控制器。但是一旦我们这么做了,由于java的单继承机制,将使得以后若要对控制器进行变更的时候变得很难受。

接下来,让我们学习另外一种方法,该方法可以全局处理异常并且不对控制器进行入侵。

3. 方法2: HandlerExceptionResolver

第二种处理异常的方法是定义一个可以处理应用所抛出的所有异常的HandlerExceptionResolver。其可以处理所有异常的特性,决定了使用该方法可以在REST API应用中实现统一的异常处理机制(uniform exception handling mechanism )。

在自定义异常处理器以前,让我们看看学习下已有的实现。

3.1 ExceptionHandlerExceptionResolver

该异常处理器在Spring 3.1中被引入并被DispatcherServlet默认启用。它也是方法一中的@ExceptionHandler处理异常的核心组件。

3.2 DefaultHandlerExceptionResolver

该异常处理器在Spring 3.0中被引入并被DispatcherServlet默认启用。Spring使用该处理器处理了常见的客户端错误(比如常见的400、404、405、406错误)和服务器错误(500)。点此查看Spring用当前异常处理器处理的异常以及各个异常对应返回的状态。

    @GetMapping("/notSupported")
    public void notSupported() throws HttpRequestMethodNotSupportedException {
        throw new HttpRequestMethodNotSupportedException("get", "message");
    }

此处理器的缺点在于:当我们使用此机制处理异常时,异常发生后仅仅是给客户端发送了对应的状态码,而在响应的主体信息中什么都没有。

GET http://localhost:8080/foo/notSupported

HTTP/1.1 405 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 26 Jul 2020 02:20:40 GMT
Keep-Alive: timeout=60
Connection: keep-alive

<Response body is empty>

Response code: 405; Time: 127ms; Content length: 0 bytes

在REST API中,大多时候仅仅查看状态码是远远不够的。我们希望能够在发生错误的时候可以获取到更多的信息以帮助我们排查发生错误的真实原因。

虽然此问题可以通过借助一些其它的方法的解决,但我们并不推荐这么做。这也是为什么Spring 3.2引入了一个我们在本文稍后介绍的更好解决方案。

3.3. ResponseStatusExceptionResolver

此异常处理器同样在Spring 3.0中被引入并且被由DispatcherServlet默认启用。它的使用方法是:在自定义的异常上加上@ResponseStatus注解,以达到将捕获的异常转换为HTTP状态码的止的。

示例代码如下:

/**
* 当发生MyResourceNotFoundException异常时,返回HttpStatus.NOT_FOUND对应的状态码:404
*/
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
    public MyResourceNotFoundException() {
        super();
    }
    public MyResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyResourceNotFoundException(String message) {
        super(message);
    }
    public MyResourceNotFoundException(Throwable cause) {
        super(cause);
    }
}
    @GetMapping("resourceNotFound")
    public void resourceNotFound() {
        throw new MyResourceNotFoundException("resourceNotFound exception message");
    }

DefaultHandlerExceptionResolver,虽然该方法返回了主体信息,但主体可定义的空间有限,同样不能很发了的满足我们的需求。

GET http://localhost:8080/foo/resourceNotFound

HTTP/1.1 404 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 26 Jul 2020 02:26:10 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "timestamp": "2020-07-26T02:26:10.512+0000",
  "status": 404,
  "message": "resourceNotFound exception message",
  "path": "/foo/resourceNotFound",
  "locale": "en_CN"
}

Response code: 404; Time: 157ms; Content length: 152 bytes

3.4. SimpleMappingExceptionResolver 和 AnnotationMethodHandlerExceptionResolver

SimpleMappingExceptionResolver已步入老年,并不适合于REST服务。

AnnotationMethodHandlerExceptionResolver在Spring 3.0中被引用,通过@ExceptionHandler来处理异常,该方法在Spring 3.2中已被弃用,取而代之的是ExceptionHandlerExceptionResolver

在此不多做介绍。

3.5 自定义异常处理器

DefaultHandlerExceptionResolverResponseStatusExceptionResolver结合使用能够提供一个不错的异常处理机制。缺点我们前面也提过了:返回的主体信息的信息较空洞,客户端无法根据状态码以及较为空洞的主体信息来更精确的判断出错误产生的原因。

在单一后台多前台的情景中,我们应该根据用户请求Header信息中的Accept值来决定返回的数据格式:JSON或XML或HTML。

以下代码展示了如何创建一个自定义异常处理器并根据Header中的Accept来返回不同的错误信息:

 @Component
public class CustomExceptionHandlerExceptionResolver extends AbstractHandlerExceptionResolver {
    @Override
    protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                return handleIllegalArgument(
                        (IllegalArgumentException) ex, request, response);
            }
        } catch (Exception handlerException) {
            logger.warn("处理异常过程当中发生了异常");
        }
        return null;
    }

    private ModelAndView
    handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        String accept = request.getHeader(MediaType.APPLICATION_JSON_VALUE);
        logger.info(accept);
        response.sendError(HttpServletResponse.SC_CONFLICT);
        return new ModelAndView();
    }
}

观察上述代码注释的部分不难看出,在异常的处理过程中我们获取了request请求头中的accept字段的值。有了这个值,便可以根据该值决定该异常是应该返回是JSON还是XML了。

比如我们获取到的accept值为 application/json,那么原则上(实际在Spring Boot中已经被统一处理)就可以返回JSON格式的数据。

上述代码中的返回值类型为ModelAndView,可以把它看成就是响应的主体,原则上(实际上Spring Boot忽略了返回值ModelAndView)自定义返回的 ModelAndView 便可以达到自定义返回主体内容的目的。

本方法不失为一种统一、简单的异常处理机制。但是它仍然有以下不同忽视的缺点:1. 该方法可以与低级的HtttpServletResponse交互(实际上除非真的有必要,我们应该尽量避免直接与HtttpServletResponse打交道)。2. ModelAndView已经成为了过去时,我们在新的项目中基本上已经看不到这种用法了。3. 在Spring Boot中,将忽略返值的ModelAndView。4. Spring自己使用的异常处理器并没有在此处理ModelAndView

4. 方法3 ---- @ControllerAdvice

Spring 3.2提供了一种使用 @ExceptionHandler @ControllerAdvice 注解进行全局异常处理的方法。该方法彻底弃用了过时的MVC模块,并且使用了ResponseEntity做为异常的返回值,该返回值兼顾了返回数据的灵活性及安全性。

@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
    @ExceptionHandler(value
            = {IllegalArgumentException.class, IllegalStateException.class, CustomException3.class})
    protected ResponseEntity<Object> handleConflict(
            IllegalArgumentException ex, WebRequest request) {
        // 可以调用handleExceptionInternal方法在主体中返回任意类型
        HashMap<String, String> hashMap = new HashMap<>(10);
        hashMap.put("key", "value");
        return handleExceptionInternal(ex, hashMap,
                new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

@ControllerAdvice 将过去多个 @ExceptionHandlers 集中到一起,这使得我们可以在当前一个类中统一处理所有的异常。

它提从了一种即简单又灵活的机制:

  • 即可以设置返回的HTTP状态码,又可以设置返回的主体内容。
  • 在一个方法中可以同时处理多个异常,并且
  • 返回了安全、灵活的ResposeEntity响应。

值得注意的是 @ExceptionHandler 注解设置的异常将做为其注解对应方法的参数传入。如果某个异常被设置于 @ExceptionHandler 但却与对应方法中参数的类型不相匹配,在程序运行时就会报错。但是此错误不能被编辑译发现,因为编译器没有必要也没有理由发现此类错误。

比如上述代码中的方法变更为:

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler(value 
      = { IllegalArgumentException.class, IllegalStateException.class, CustomException3.class })
    // 参数的第一个类型由RuntimeException变更为IllegalArgumentException
    protected ResponseEntity<Object> handleConflict(
      IllegalArgumentException ex, WebRequest request) {
        HashMap<String, String> hashMap = new HashMap<>(10);
        hashMap.put("key", "value");
        return handleExceptionInternal(ex, hashMap,
                new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

此时在运行过程中当发生CustomException3异常时,当会发生如下错误:

java.lang.IllegalStateException: Could not resolve parameter [0] in protected org.springframework.http.ResponseEntity<java.lang.Object> cn.baeldung.demo.RestResponseEntityExceptionHandler.handleConflict(java.lang.IllegalArgumentException,org.springframework.web.context.request.WebRequest): No suitable resolver

5. 方法4 ---- ResponseStatusException(Spring 5及以上版本)

Spring 5引入了ResponseStatusException异常类。我们可以利用该类很轻松的抛出带有状态码、错误原因的异常:

    @Autowired
    FooService fooService;

    @GetMapping(value = "/{id}")
    public Foo findById(@PathVariable Long id) {
        try {
            return this.fooService.findById(id);
        } catch (FooNotFoundException exc) {
            throw new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "Foo Not Found", exc);
        }
    }

使用ResponseStatusException的优点可以简单的归纳为以下几点:

  • 快速生成原型:可以很快的完成基本的解决方案,这对生成 demo时比较有用。
  • 可以应用单一的异常类型,返回不同的状态码。可以不必为不同的状态的定义不同的异常类型了。
  • 由于可以应用单一异常,所以省去了创建过多的自定义异常的过程。
  • 在控制器中有效的控制该方法异常的返回值,使得控制器在对异常返回拥有更多的控制权。

它的缺点是:

  • 由于没有进行统一的异常处理,各个控制器直接返回异常的规范很难相同,程序越大统一规范就越难。随着项目的增大,对异常的统一处理将会变得越来越头疼。
  • 在控制器的每个方法中都增加try catch语句,而且大多数数的语句也将一模一样,这造成的过多的语句冗余。

当然了,我们完全可以根据项止的实际需求,将上述的异常处理方法结合来使用。

比如说我们可以实现一个全局的 @ControllerAdvice ,同时也可以在某些控制器中抛出 ResponseStatusException

如果在项目中我们使用了不止一种方法来处理异常,那么需要特别注意的是:由于某种特定的异常最终仅会被一个方法来处理,所以在进行异常处理的时需要保证某种异常仅仅被定义在一个方法之中。

如果你想获取有关ResponseStatusException的更多信息,或许这篇ResponseStatusException入门教程能够帮到你。

6. 在Spring Security处理禁止访问(Access Denied)

当用户访问其没有权限的资源时会发生禁止访问(Access Denied)错误。

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

6.1 MVC ---- 自定义错误页

首页让我们看看Sring MVC是怎么处理禁止访问的错误页的:

使用XML进行配置如下:

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/>   
    ... 
    <access-denied-handler error-page="/my-error-page" />
</http>

或者使用JAVA配置如下:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/admin/*").hasAnyRole("ADMIN")
            ...
            .exceptionHandling().accessDeniedPage("/my-error-page");
    }

此时,如果用户尝试访问其没有权限的资源时,则会被重定向至 "/my-error-page".

6.2 自定义禁止访问处理器

接下来,让我们看看如何定义一个自定义禁止访问处理器:

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle
      (HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) 
      throws IOException, ServletException {
        response.sendRedirect("/my-error-page");
    }
}

使用XML配置如下:

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/> 
    ...
    <access-denied-handler ref="customAccessDeniedHandler" />
</http>

或者使用JAVA配置如下:

@Autowired
private CustomAccessDeniedHandler accessDeniedHandler;
 
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/admin/*").hasAnyRole("ROLE_ADMIN")
        ...
        .and()
        .exceptionHandling().accessDeniedHandler(accessDeniedHandler)
}

此时,我们便可以在CustomAccessDeniedHandler按自己的想法来设置错误响应信息或是如上代码一样将用户引导至重定向的页面了。

@RestController
public class ErrorController {

    @GetMapping("/my-error-page")
    public String error() {
        return "this is my-error-page";
    }
}

6.3. REST以及方法(Method)级别的异常控制

最后,让我们看看如何处理Spring Security中的 @PreAuthorize@PostAuthorize, and @Secure三个权限注解。

由于上述三个注解在验证失败时将对应抛出了AccessDeniedException,所以可以使用全局异常机制非常轻构的处理安全异常:

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler({ AccessDeniedException.class })
    public ResponseEntity<Object> handleAccessDeniedException(
      Exception ex, WebRequest request) {
        return new ResponseEntity<Object>(
          "Access denied message here", new HttpHeaders(), HttpStatus.FORBIDDEN);
    }
    
    ...
}

7. Spring Boot下的异常处理

Spring Boot提供了一个ErrorController以一种非常聪明的方式来处理异常信息。

简而言之,如果通过浏览器来访问应用发生异常,则会返回给用户一个空白错误页面(又被称为:Whitelabel Error Page) ; 如果是发起的RESTful请求,则返回返回json格式的错误信息:

{
  "timestamp": "2020-07-25T01:25:52.192+0000",
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/SpringSecurity/preAuthorize1212312"
}

同时,可以通过配置选择来改变上述配置:

  • server.error.whitelabel.enabled 此值设置为false将禁用空白错误页面,而是返回servlet(比如tomcat)的html错误信息。
  • 同时将server.error.include-stacktrace 此值设置为always,将在报错的信息中显示异常的详细信息。

我们还可以自定义一个错误界面以替代Whitelabel页面。也可以通过继承DefaultErrorAttributes来非常轻松地自定义返回的异常信息:

@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {
 
    @Override
    public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, boolean includeStackTrace) {
        Map<String, Object> errorAttributes = 
          super.getErrorAttributes(webRequest, includeStackTrace);
        // 添加local信息
        errorAttributes.put("locale", webRequest.getLocale()
            .toString());
        // 移除原error信息
        errorAttributes.remove("error");
 
        //...
 
        return errorAttributes;
    }
}

如果我们想进一步的定义(或是覆盖)应用程序在上下文中处理错误的方法,我们还可以注册一个ErrorController bean。

同样的,我们可以使用Spring Boot提供的BasicErrorController来快速达到这一目标。比如我们想自定义XML格式的错误返回内容,则可以使用 @RequestMapping 并指向 application/xml :

@Component
public class MyErrorController extends BasicErrorController {
    public MyErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes, new ErrorProperties());
    }

    @RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public ResponseEntity<Map<String, Object>> jsonError(HttpServletRequest request) {
        // 以下开始自定义返回内容:在返回值中添加test字段
        Map<String, Object> map = super.getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
        map.put("test", "test value");
        return new ResponseEntity<Map<String, Object>>(
                map, getStatus(request)
        );
    }
}

8. 总结

本文讨论了几种Spring REST API处理异常的几种方法。其中涵盖了Spring 3.0、3.1、3.2、4.X、5.X的内容。希望能你能有所帮助。

分类
REST spring

Spring REST实体(entity)与数据传输对象(dto)间的转换

资源列表:

在正式开始前,可以点以下链接获取一份与本文相同的初始化代码。

1. 概述

在实际的开发中,直接将内部的实体返回给客户端并不是明智的选择。这不仅直接暴露了项目的数据结构,还需要使用JsonView定制返回的数据字段,最主要的一旦实体结构发生变更,将直接影响发布的API文档。

本文将阐述如何将Spring内部实体转换为外部数据传输对象(DTOs ---- Data Transfer Objects)的方法,使用该方法返回数据很好的规避上述问题。

2. Model Mapper

我们将使用一款名为ModelMapper的entity-DTO转换库来完成这一功能。

首先打开pom.xml并添加如下依赖:

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.3.5</version>
</dependency>

如果你想用最新的版本的ModelMapper,请点击查看

接下来在Spring配置类中如下定义ModelMapper bean。

@Bean
public ModelMapper modelMapper() {
    return new ModelMapper();
}

3. DTO

接下来让我们建立第一个DTO类 -- Post DTO

public class PostDto {
    private static final SimpleDateFormat dateFormat
      = new SimpleDateFormat("yyyy-MM-dd HH:mm");
 
    private Long id;
 
    private String title;
 
    private String url;
 
    private String date;
 
    private UserDto user;
 
    public Date getSubmissionDateConverted(String timezone) throws ParseException {
        dateFormat.setTimeZone(TimeZone.getTimeZone(timezone));
        return dateFormat.parse(this.date);
    }
 
    public void setSubmissionDate(Date date, String timezone) {
        dateFormat.setTimeZone(TimeZone.getTimeZone(timezone));
        this.date = dateFormat.format(date);
    }
 
    // 以下省略标准的getters与setters
}

在上述代码中我们使用了两个时间相关的方法来对Date日期类型进行转换(将Date类型的日期转换为String类型的日期),该方法为服务端的日期格式(Date类型的日期)与客户端的日期格式(String类的日期)搭起一做转换的桥梁。

  • getSubmissionDateConverted()方法将String类型的日期根据时区转换为Date类型的日期。该方法将用于持久化Post实体时。
  • setSubmissionDate()方法将Date类型的日期依据时区转换为String类型的日期。

4. 服务层

服务层(比如PostServiceImpl.java)的示例代码如下:

@Service
public class PostServiceImpl implements PostService {
    @Autowired
    PostRepository postRepository;
    @Override
    public List<Post> findAll() {
        return (List<Post>) this.postRepository.findAll();
    }

    @Override
    public Post save(Post post) {
        return this.postRepository.save(post);
    }

    @Override
    public Post findById(Long id) {
        return this.postRepository.findById(id).get();
    }
}

接下来,我们将重点放在调用服务层(service layer)的控制器层(controller layer),这是因为实际的数据转换将发生在控制器层。

5. 控制器层

下面,让我们看一个标准的控制器实现,为Post资源暴露一个简单的REST接口。

以下代码中将展示一些简单的CRUD操作:创建、更新、获取某条数据以及获取全部数据。CRUD是常规的操作,所以在阅读以下代码时,可以重点关注实体与数据传输对象的转换过程(Entity-DTO conversion):

@RestController
public class PostRestController {

    @Autowired
    PostService postService;
    @Autowired
    ModelMapper modelMapper;

    @PostMapping
    public PostDto save(@RequestBody PostDto postDto) throws ParseException {
        Post post = this.convertToEntity(postDto);
        return this.convertToDto(this.postService.save(post));
    }

    @GetMapping("/{id}")
    public PostDto getById(@PathVariable Long id) {
        return this.convertToDto(this.postService.findById(id));
    }

    @GetMapping
    public List<PostDto> getAll() {
        return this.postService.findAll()
                               .stream().map(this::convertToDto)
                               .collect(Collectors.toList());
    }
}

以下代码展示了实体Post向数据传输对象PostDto的转换过程:

    private PostDto convertToDto(Post post) {
        PostDto postDto = modelMapper.map(post, PostDto.class);
        postDto.setSubmissionDate(post.getSubmissionDate(), "GMT+8:00");
        return postDto;
    }

以下代码展示了数据传输对象PostDto像实体Post的转换过程:

    private Post convertToEntity(PostDto postDto) throws ParseException {
        Post post = modelMapper.map(postDto, Post.class);
        post.setSubmissionDate(postDto.getSubmissionDateConverted(
                "GMT+8:00"));

        return post;
    }

从以上代码不难看出,ModelMapper使得实体与数据传输对象(DTO)之间的转换变得即快又简单 ---- 我们使用了ModelMappermap接口,无需编写任何转换的代码便实现了数据转换。

6. 单元测试

最后,让我们做个简单的测试以保障本文前面涉及代码的正确性:

public class PostDtoUnitTest {
 
    private ModelMapper modelMapper = new ModelMapper();
 
    @Test
    public void whenConvertPostEntityToPostDto_thenCorrect() {
        Post post = new Post();
        post.setId(1L);
        post.setTitle(randomAlphabetic(6));
        post.setUrl("www.test.com");
 
        PostDto postDto = modelMapper.map(post, PostDto.class);
        assertEquals(post.getId(), postDto.getId());
        assertEquals(post.getTitle(), postDto.getTitle());
        assertEquals(post.getUrl(), postDto.getUrl());
    }
 
    @Test
    public void whenConvertPostDtoToPostEntity_thenCorrect() {
        PostDto postDto = new PostDto();
        postDto.setId(1L);
        postDto.setTitle(randomAlphabetic(6));
        postDto.setUrl("www.test.com");
 
        Post post = modelMapper.map(postDto, Post.class);
        assertEquals(postDto.getId(), post.getId());
        assertEquals(postDto.getTitle(), post.getTitle());
        assertEquals(postDto.getUrl(), post.getUrl());
    }
}

7. 总结

本文展示了使用ModelMapper来完成实体与数据传输对象(DTO) 之间的快速转换方法,相较于传统的转换方法,在使用该方法进行Entity-DTO转换时可以大幅减少代码的书写量。

尺有所短、寸有所长,本文展示的方法并不能很好的适应实体字段名称、实体结构变更的情况,在使用本方法的过程中还应该结合良好的控制器单元测试来保障项目的开发质量。

分类
Series Spring Security

Security with Spring系列教程