8.6 Spring Boot集成Spring Security

开发Web应用,对页面的安全控制通常是必须的。比如:对于没有访问权限的用户需要转到登录表单页面。要实现访问控制的方法多种多样,可以通过Aop、拦截器实现,也可以通过框架实现,例如:Apache Shiro、Spring Security。

很多成熟的大公司都会有专门针对用户管理方面有一套完整的SSO(单点登录),ACL(权限访问控制),UC(用户中心)系统。 但是在我们开发中小型系统的时候,往往还是优先选择轻量级可用的业内通用的框架解决方案。

Spring Security 就是一个Spring生态中关于安全方面的框架。它能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案。

Spring Security,是一个基于Spring AOP和Servlet过滤器的安全框架。它提供全面的安全性解决方案,同时在Web请求级和方法调用级处理身份确认和授权。在Spring Framework基础上,Spring Security充分利用了依赖注入(DI,Dependency Injection)和面向切面技术。

Spring Security提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC(Inversion of Control, 控制反转),DI和AOP(Aspect Oriented Progamming ,面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作,为基于J2EE企业应用软件提供了全面安全服务[0]。Spring Security的前身是 Acegi Security 。

本章节使用SpringBoot集成Spring Security开发一个LightSword接口自动化测试平台,由浅入深的讲解SpringBoot集成Spring Security开发技术知识。

本章节采用SpringBoot集成的主要的后端技术框架:

编程语言:java,scala
ORM框架:jpa
View模板引擎:velocity
安全框架:spring security
数据库:mysql

初阶 Security: 默认认证用户名密码

项目pom.xml添加spring-boot-starter-security依赖

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

重启你的应用。再次打开页面,你讲看到一个alert表单对话框:

8.6 Spring Boot集成Spring Security_登录页面

这个用户名,密码是什么呢?

让我们来从SpringBoot源码寻找一下。

你搜一下输出日志,会看到下面一段输出:

2017-04-27 21:39:20.321  INFO 94124 --- [ost-startStop-1] b.a.s.AuthenticationManagerConfiguration : 

Using default security password: 6c920ced-f1c1-4604-96f7-f0ce4e46f5d4

这段日志是AuthenticationManagerConfiguration类里面的如下方法输出的:

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
if (auth.isConfigured()) {
return;
}
User user = this.securityProperties.getUser();
if (user.isDefaultPassword()) {
logger.info(String.format("%n%nUsing default security password: %s%n",
user.getPassword()));
}
Set<String> roles = new LinkedHashSet<>(user.getRole());
withUser(user.getName()).password(user.getPassword())
.roles(roles.toArray(new String[roles.size()]));
setField(auth, "defaultUserDetailsService", getUserDetailsService());
super.configure(auth);
}

我们可以看出,是SecurityProperties这个Bean管理了用户名和密码。
在SecurityProperties里面的一个内部静态类User类里面,管理了默认的认证的用户名与密码。代码如下

public static class User {

/**
* Default user name.
*/
private String name = "user";

/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();

/**
* Granted roles for the default user name.
*/
private List<String> role = new ArrayList<>(Collections.singletonList("USER"));

private boolean defaultPassword = true;

public String getName() {
return this.name;
}

public void setName(String name) {
this.name = name;
}

public String getPassword() {
return this.password;
}

public void setPassword(String password) {
if (password.startsWith("${") && password.endsWith("}")
|| !StringUtils.hasLength(password)) {
return;
}
this.defaultPassword = false;
this.password = password;
}

public List<String> getRole() {
return this.role;
}

public void setRole(List<String> role) {
this.role = new ArrayList<>(role);
}

public boolean isDefaultPassword() {
return this.defaultPassword;
}

}

综上所述,security默认的用户名是user, 默认密码是应用启动的时候,通过UUID算法随机生成的。默认的role是"USER"。

当然,如果我们想简单改一下这个用户名密码,可以在application.properties配置你的用户名密码,例如

# security
security.user.name=admin
security.user.password=admin

当然这只是一个初级的配置,更复杂的配置,可以分不用角色,在控制范围上,能够拦截到方法级别的权限控制。 且看下文分解。

中阶 Security:内存用户名密码认证

在上面章节,我们什么都没做,就添加了spring-boot-starter-security依赖,整个应用就有了默认的认证安全机制。下面,我们来定制用户名密码。

写一个extends WebSecurityConfigurerAdapter的配置类:

package com.springboot.in.action.security;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
* Created by jack on 2017/4/27.
*/

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("root")
.password("root")
.roles("USER");
}

}

简要说明:

1.通过 @EnableWebSecurity注解开启Spring Security的功能。使用@EnableGlobalMethodSecurity(prePostEnabled = true)这个注解,可以开启security的注解,我们可以在需要控制权限的方法上面使用@PreAuthorize,@PreFilter这些注解。

2.extends 继承 WebSecurityConfigurerAdapter 类,并重写它的方法来设置一些web安全的细节。我们结合@EnableWebSecurity注解和继承WebSecurityConfigurerAdapter,来给我们的系统加上基于web的安全机制。

3.在configure(HttpSecurity http)方法里面,默认的认证代码是:

http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();

从方法名我们基本可以看懂这些方法的功能。上面的那个默认的登录页面,就是SpringBoot默认的用户名密码认证的login页面。其源代码如下:

<html><head><title>Login Page</title></head><body notallow='document.f.username.focus();'>
<h3>Login with Username and Password</h3><form name='f' actinotallow='/login' method='POST'>
<table>
<tr><td>User:</td><td><input type='text' name='username' value=''></td></tr>
<tr><td>Password:</td><td><input type='password' name='password'/></td></tr>
<tr><td colspan='2'><input name="submit" type="submit" value="Login"/></td></tr>
<input name="_csrf" type="hidden" value="b2155184-80cf-48a2-b547-91bbe364c98e" />
</table>
</form></body></html>

我们使用SpringBoot默认的配置super.configure(http),它通过 authorizeRequests() 定义哪些URL需要被保护、哪些不需要被保护。默认配置是所有访问页面都需要认证,才可以访问。

4.通过 formLogin() 定义当需要用户登录时候,转到的登录页面。

5.configureGlobal(AuthenticationManagerBuilder auth) 方法,在内存中创建了一个用户,该用户的名称为root,密码为root,用户角色为USER。

我们再次启动应用,访问 ​​http://localhost:8888​​​页面自动跳转到: ​​http://localhost:8888/login​​如下图所示:

8.6 Spring Boot集成Spring Security_spring_02

这个默认的登录页面是怎么冒出来的呢?是的,SpringBoot内置的,SpringBoot甚至给我们做好了一个极简的登录页面。这个登录页面是通过Filter实现的。具体的实现类是org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter。同时,这个DefaultLoginPageGeneratingFilter也是SpringBoot的默认内置的Filter。

输入用户名,密码,点击Login

8.6 Spring Boot集成Spring Security_spring_03

成功跳转我们之前要访问的页面:

8.6 Spring Boot集成Spring Security_登录页面_04

不过,我们发现,SpringBoot应用的启动日志还是打印了如下一段:

2017-04-27 22:51:44.059  INFO 95039 --- [ost-startStop-1] b.a.s.AuthenticationManagerConfiguration : 

Using default security password: 5fadfb54-2096-4a0b-ad46-2dad3220c825

但实际上,已经使用了我们定制的用户名密码了。

如果我们要配置多个用户,多个角色,可参考使用如下示例的代码:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("root")
.password("root")
.roles("USER")
.and()
.withUser("admin").password("admin")
.roles("ADMIN", "USER")
.and()
.withUser("user").password("user")
.roles("USER");
}

角色权限控制

当我们的系统功能模块当需求发展到一定程度时,会不同的用户,不同角色使用我们的系统。这样就要求我们的系统可以做到,能够对不同的系统功能模块,开放给对应的拥有其访问权限的用户使用。

Spring Security提供了Spring EL表达式,允许我们在定义URL路径访问(@RequestMapping)的方法上面添加注解,来控制访问权限。

在标注访问权限时,根据对应的表达式返回结果,控制访问权限:

true,表示有权限
fasle,表示无权限

Spring Security可用表达式对象的基类是SecurityExpressionRoot。

package org.springframework.security.access.expression;

import java.io.Serializable;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;

/**
* Base root object for use in Spring Security expression eval(Authentication authentication) {
if (authentication == null) {
throw new IllegalArgumentException("Authentication object cannot be null");
}
this.authentication = authentication;
}

public final boolean hasAuthority(String authority) {
return hasAnyAuthority(authority);
}

public final boolean hasAnyAuthority(String... authorities) {
return hasAnyAuthorityName(null, authorities);
}

public final boolean hasRole(String role) {
return hasAnyRole(role);
}

public final boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(defaultRolePrefix, roles);
}

private boolean hasAnyAuthorityName(String prefix, String... roles) {
Set<String> roleSet = getAuthoritySet();

for (String role : roles) {
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}

return false;
}

public final Authentication getAuthentication() {
return authentication;
}

public final boolean permitAll() {
return true;
}

public final boolean denyAll() {
return false;
}

public final boolean isAnonymous() {
return trustResolver.isAnonymous(authentication);
}

public final boolean isAuthenticated() {
return !isAnonymous();
}

public final boolean isRememberMe() {
return trustResolver.isRememberMe(authentication);
}

public final boolean isFullyAuthenticated() {
return !trustResolver.isAnonymous(authentication)
&& !trustResolver.isRememberMe(authentication);
}

/**
* Convenience method to access {@link Authentication#getPrincipal()} from
* {@link #getAuthentication()}
* @return
*/
public Object getPrincipal() {
return authentication.getPrincipal();
}

public void setTrustResolver(AuthenticationTrustResolver trustResolver) {
this.trustResolver = trustResolver;
}

public void setRoleHierarchy(RoleHierarchy roleHierarchy) {
this.roleHierarchy = roleHierarchy;
}

/**
* <p>
* Sets the default prefix to be added to {@link #hasAnyRole(String...)} or
* {@link #hasRole(String)}. For example, if hasRole("ADMIN") or hasRole("ROLE_ADMIN")
* is passed in, then the role ROLE_ADMIN will be used when the defaultRolePrefix is
* "ROLE_" (default).
* </p>
*
* <p>
* If null or empty, then no default role prefix is used.
* </p>
*
* @param defaultRolePrefix the default prefix to add to roles. Default "ROLE_".
*/
public void setDefaultRolePrefix(String defaultRolePrefix) {
this.defaultRolePrefix = defaultRolePrefix;
}

private Set<String> getAuthoritySet() {
if (roles == null) {
roles = new HashSet<String>();
Collection<? extends GrantedAuthority> userAuthorities = authentication
.getAuthorities();

if (roleHierarchy != null) {
userAuthorities = roleHierarchy
.getReachableGrantedAuthorities(userAuthorities);
}

roles = AuthorityUtils.authorityListToSet(userAuthorities);
}

return roles;
}

public boolean hasPermission(Object target, Object permission) {
return permissionEvaluator.hasPermission(authentication, target, permission);
}

public boolean hasPermission(Object targetId, String targetType, Object permission) {
return permissionEvaluator.hasPermission(authentication, (Serializable) targetId,
targetType, permission);
}

public void setPermissionEvaluator(PermissionEvaluator permissionEvaluator) {
this.permissionEvaluator = permissionEvaluator;
}

/**
* Prefixes role with defaultRolePrefix if defaultRolePrefix is non-null and if role
* does not already start with defaultRolePrefix.
*
* @param defaultRolePrefix
* @param role
* @return
*/
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
if (role == null) {
return role;
}
if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) {
return role;
}
if (role.startsWith(defaultRolePrefix)) {
return role;
}
return defaultRolePrefix + role;
}
}

通过阅读源码,我们可以更加深刻的理解其EL写法,并在写代码的时候正确的使用。变量defaultRolePrefix硬编码约定了role的前缀是"ROLE_"。

同时,我们可以看出hasRole跟hasAnyRole是一样的。hasAnyRole是调用的hasAnyAuthorityName(defaultRolePrefix, roles)。所以,我们在学习一个框架或者一门技术的时候,最准确的就是源码。通过源码,我们可以更好更深入的理解技术的本质。

SecurityExpressionRoot为我们提供的使用Spring EL表达式总结如下[1]:

表达式

描述

hasRole([role])

当前用户是否拥有指定角色。

hasAnyRole([role1,role2])

多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true。

hasAuthority([auth])

等同于hasRole

hasAnyAuthority([auth1,auth2])

等同于hasAnyRole

Principle

代表当前用户的principle对象

authentication

直接从SecurityContext获取的当前Authentication对象

permitAll

总是返回true,表示允许所有的

denyAll

总是返回false,表示拒绝所有的

isAnonymous()

当前用户是否是一个匿名用户

isRememberMe()

表示当前用户是否是通过Remember-Me自动登录的

isAuthenticated()

表示当前用户是否已经登录认证成功了。

isFullyAuthenticated()

如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true。

比如说,在lightsword系统中,我们设置测试报告页面,只针对ADMIN权限开放,代码如下:

package com.springboot.in.action.controller

import java.util

import com.alibaba.fastjson.serializer.SerializerFeature
import com.springboot.in.action.dao.HttpReportDao
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.{RequestMapping, RestController}
import org.springframework.web.servlet.ModelAndView

import scala.collection.JavaConversions._

@RestController
@RequestMapping(Array("/httpreport"))
class HttpReportController @Autowired()(val HttpReportDao: HttpReportDao) {

@RequestMapping(value = {
Array("", "/")
})
@PreAuthorize("hasRole('ADMIN')") // Spring Security默认的角色前缀是”ROLE_”,使用hasRole方法时已经默认加上了
def list(model: Model) = {
val reports = HttpReportDao.findAll
model.addAttribute("reports", reports)

val rateList = new util.ArrayList[Double]

val trendList = new util.ArrayList[Object]

for (r <- reports) {
rateList.add(r.rate)

// QualityTrend
val qt = new util.HashMap[String, Any]

qt.put("id", r.id)
qt.put("failed", r.fail)
qt.put("totalCases", r.pass + r.fail)
qt.put("rate", r.rate)
trendList.add(qt)
}

val jsonstr = com.alibaba.fastjson.JSON.toJSONString(trendList, SerializerFeature.BrowserCompatible)
println(jsonstr)

model.addAttribute("rateList", rateList)
model.addAttribute("trendList", jsonstr)

new ModelAndView("/httpreport/list")
}

}

然后,我们配置用户user为USER权限:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("root")
.password("root")
.roles("ADMIN", "USER")
.and()
.withUser("admin").password("admin")
.roles("ADMIN", "USER")
.and()
.withUser("user").password("user")
.roles("USER");
}

重启应用,用用户名:user,密码:user登录系统,访问/httpreport页面,我们将会看到如下,不允许访问的报错页面:

8.6 Spring Boot集成Spring Security_用户名_05

简要说明

在方法上添加@PreAuthorize这个注解,value="hasRole('ADMIN')")是Spring-EL expression,当表达式值为true,标识这个方法可以被调用。如果表达式值是false,标识此方法无权限访问。

本小节完整的工程代码:
​​​https://github.com/EasySpringBoot/lightsword/tree/spring_security_with_in_memory_auth​

在Spring Security里面获取当前登录认证通过的用户信息

如果我们想要在前端页面显示当前登录的用户怎么办呢?在在Spring Security里面怎样获取当前登录认证通过的用户信息?下面我们就来探讨这个问题。

很好办。我们添加一个LoginFilter,默认拦截所有请求,把当前登录的用户放到系统session中即可。在Spring Security中,用户信息保存在SecurityContextHolder中。Spring Security使用一个Authentication对象来持有所有系统的安全认证相关的信息。这个信息的内容格式如下:

{
"accountNonExpired":true,
"accountNonLocked":true,
"authorities":[{
"authority":"ROLE_ADMIN"
},{
"authority":"ROLE_USER"
}],
"credentialsNonExpired":true,
"enabled":true,
"username":"root"
}

这个Authentication对象信息其实就是User实体的信息(当然,密码没放进来)。

public class User implements UserDetails, CredentialsContainer {
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
....
}

我们可以使用下面的代码(Java)获得当前身份验证的用户的名称:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

通过调用getContext()返回的对象是SecurityContext的实例对象,该实例对象保存在ThreadLocal线程本地存储中。使用Spring Security框架,通常的认证机制都是返回UserDetails实例。

Spring MVC的 Web开发使用 Controller 基本上可以完成大部分需求,但是我们还可能会用到 Servlet、Filter、Listener、Interceptor 等等。

在Spring Boot中添加自己的Servlet有两种方法,代码注册Servlet和注解自动注册(Filter和Listener也是如此)。

(1)代码注册通过ServletRegistrationBean、 FilterRegistrationBean 和 ServletListenerRegistrationBean 获得控制。
也可以通过实现 ServletContextInitializer 接口直接注册。使用代码注册Servlet(就不需要@ServletComponentScan注解)

(2)在 SpringBootApplication 上使用@ServletComponentScan 注解后,Servlet、Filter、Listener 可以直接通过 @WebServlet、@WebFilter、@WebListener 注解自动注册。

下面我们就采用第(2)种方法,通过添加一个LoginFilter,拦截所有请求,把当前登录信息放到系统session中,并在前端页面显示。

1.添加一个实现了javax.servlet.Filter的LoginFilter,把当前登录信息放到系统session中

代码如下

package com.springboot.in.action.filter

import javax.servlet._
import javax.servlet.annotation.WebFilter
import javax.servlet.http.HttpServletRequest

import com.alibaba.fastjson.JSON
import com.alibaba.fastjson.serializer.SerializerFeature
import org.springframework.core.annotation.Order

/**
* Created by jack on 2017/4/28.
*/
@Order(1) //@Order注解表示执行过滤顺序,值越小,越先执行
@WebFilter(filterName = "loginFilter", urlPatterns = Array("/*")) //需要在spring-boot的入口处加注解@ServletComponentScan, 如果不指定,默认url-pattern是/*
class LoginFilter extends Filter {
override def init(filterConfig: FilterConfig): Unit = {}

override def destroy(): Unit = {}

override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain): Unit = {
val session = request.asInstanceOf[HttpServletRequest].getSession

import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails

val principal = SecurityContextHolder.getContext.getAuthentication.getPrincipal


println("LoginFilter:" + JSON.toJSONString(principal, SerializerFeature.PrettyFormat))

var username = ""
if (principal.isInstanceOf[UserDetails]) {
username = principal.asInstanceOf[UserDetails].getUsername
}
else {
username = principal.toString
}
session.setAttribute("username", username)

chain.doFilter(request, response)
}
}

我们通过

val principal = SecurityContextHolder.getContext.getAuthentication.getPrincipal


if (principal.isInstanceOf[UserDetails]) {
username = principal.asInstanceOf[UserDetails].getUsername
}
else {
username = principal.toString
}

拿到认证信息,然后把用户名放到session中:

session.setAttribute("username", username)
chain.doFilter(request, response)

其中,@WebFilter(filterName = "loginFilter", urlPatterns = Array("/")) ,这个注解用来声明一个Servlet的Filter,这个加注解@WebFilter的LoginFilter类必须要实现javax.servlet.Filter接口。它会在容器部署的时候扫描处理。如果不指定urlPatterns,默认url-pattern是/。这个@WebFilter注解,在SpringBoot中,要给启动类加上注解@ServletComponentScan,开启扫描Servlet组件功能。

2.给启动类加上注解@ServletComponentScan

package com.springboot.in.action

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.web.servlet.ServletComponentScan

@SpringBootApplication
@ServletComponentScan(basePackages = Array("com.springboot.in.action"))
class AppConfig

这个注解将开启扫描Servlet组件功能。那些被标注了@WebFilter,@WebServlet,@WebListener的Bean将会注册到容器中。需要注意的一点是,这个扫描动作只在当我们使用的是嵌入式Servlet容器的时候才起作用。完成Bean注册工作的类是org.springframework.boot.web.servlet.ServletComponentScanRegistrar,它实现了Spring的ImportBeanDefinitionRegistrar接口。

3.前端显示用户信息

Velocity内置了一些对象,例如:$request、$response、$session,这些对象可以在vm模版里可以直接调用。所以我们只需要使用$session取出,当初我们放进session的对应key的属性值即可。

我们在LoginFilter里面是这样放进去的:

session.setAttribute("username", username)

在前端页面直接这样取出username

<div class="pull-left info">
<p>$session.getAttribute('username')</p>
<a href="#"><i class="fa fa-circle text-success"></i> Online</a>
</div>

4.运行测试

部署应用运行,我们看一下运行日志:

2017-04-28 21:42:46.072  INFO 2961 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8888 (http)
2017-04-28 21:42:46.097 INFO 2961 --- [ main] o.apache.catalina.core.StandardService : Starting service Tomcat
2017-04-28 21:42:46.099 INFO 2961 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.0.33
2017-04-28 21:42:46.328 INFO 2961 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2017-04-28 21:42:46.328 INFO 2961 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 4325 ms
2017-04-28 21:42:46.984 INFO 2961 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'characterEncodingFilter' to: [/*]
2017-04-28 21:42:46.984 INFO 2961 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2017-04-28 21:42:46.985 INFO 2961 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'httpPutFormContentFilter' to: [/*]
2017-04-28 21:42:46.985 INFO 2961 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'requestContextFilter' to: [/*]
2017-04-28 21:42:46.987 INFO 2961 --- [ost-startStop-1] .e.DelegatingFilterProxyRegistrationBean : Mapping filter: 'springSecurityFilterChain' to: [/*]
2017-04-28 21:42:46.988 INFO 2961 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'com.springboot.in.action.filter.LoginFilter' to: [/*]
2017-04-28 21:42:46.988 INFO 2961 --- [ost-startStop-1] o.s.b.c.e.ServletRegistrationBean : Mapping servlet: 'dispatcherServlet' to [/]
2017-04-28 21:42:47.734 INFO 2961 --- [ost-startStop-1] o.s.s.web.DefaultSecurityFilterChain : Creating filter chain: org.springframework.security.web.util.matcher.AnyRequestMatcher@1, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@7a20e3e2, org.springframework.security.web.context.SecurityContextPersistenceFilter@6d522c58, org.springframework.security.web.header.HeaderWriterFilter@43ba5fdb, org.springframework.security.web.csrf.CsrfFilter@4ae04f7a, org.springframework.security.web.authentication.logout.LogoutFilter@31e3441f, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@30dfa22c, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@605f9361, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@22a03a5f, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7806751c, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@67831f83, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@134ae8c4, org.springframework.security.web.session.SessionManagementFilter@4c60d4b8, org.springframework.security.web.access.ExceptionTranslationFilter@2be01c38, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@34cb7b6d]
2017-04-28 21:42:48.105 INFO 2961 --- [ main] j.LocalContainerEntityManagerFactoryBean : Building JPA container EntityManagerFactory for persistence unit 'default'
2017-04-28 21:42:48.121 INFO 2961 --- [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [

在上面的日志里面,我们可以看到如下一行

o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'com.springboot.in.action.filter.LoginFilter' to: [/*]

这表明我们定义的LoginFilter类成功注册,路径映射到/*。同时,我们在

o.s.s.web.DefaultSecurityFilterChain     : Creating filter chain:

这行日志后面,看到SpringBoot默认创建了的那些Filter Chain。这些Filter如下:

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@7a20e3e2, 

org.springframework.security.web.context.SecurityContextPersistenceFilter@6d522c58,

org.springframework.security.web.header.HeaderWriterFilter@43ba5fdb,

org.springframework.security.web.csrf.CsrfFilter@4ae04f7a,

org.springframework.security.web.authentication.logout.LogoutFilter@31e3441f,

org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@30dfa22c,

org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@605f9361,

org.springframework.security.web.authentication.www.BasicAuthenticationFilter@22a03a5f,

org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7806751c,

org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@67831f83,

org.springframework.security.web.authentication.AnonymousAuthenticationFilter@134ae8c4,

org.springframework.security.web.session.SessionManagementFilter@4c60d4b8,

org.springframework.security.web.access.ExceptionTranslationFilter@2be01c38,

org.springframework.security.web.access.intercept.FilterSecurityInterceptor@34cb7b6d

SpringBoot在背后,为我们默默做了这么多事情。

好了,言归正传,我们使用root用户名登录,我们可以看到页面上正确展示了我们当前登录的用户,如下图

8.6 Spring Boot集成Spring Security_spring_06

SpringBoot注册Servlet、Filter、Listener的方法

我们刚才是使用@WebFilter注解一个javax.servlet.Filter的实现类来实现一个LoginFilter。

基于JavaConfig,SpringBoot同样可以使用如下的方式实现Servlet、Filter、Listener的Bean的配置:

@Configuration
public class WebConfig {


@Bean
public ServletRegistrationBean servletRegistrationBean_demo2(){
ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean();
servletRegistrationBean.addUrlMappings("/demo-servlet");
servletRegistrationBean.setServlet(new DemoServlet());
return servletRegistrationBean;
}

@Bean
public FilterRegistrationBean filterRegistrationBean(){

FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(new LoginFilter());
Set<String> set = new HashSet<String>();
set.add("/*");
filterRegistrationBean.setUrlPatterns(set);
return filterRegistrationBean;
}

@Bean
public ServletListenerRegistrationBean servletListenerRegistrationBean(){
ServletListenerRegistrationBean servletListenerRegistrationBean = new ServletListenerRegistrationBean();
servletListenerRegistrationBean.setListener(new Log4jConfigListener());
servletListenerRegistrationBean.addInitParameter("log4jConfigLocation","classpath:log4j.properties");
return servletListenerRegistrationBean;
}
}

从这里我们可以看出,JavaConfig在SpringBoot的自动配置中实现Bean注册的基本使用方式。

进阶 Security: 用数据库存储用户和角色,实现安全认证

本节我们将在我们之前的系统上,实现一个用数据库存储用户和角色,实现系统的安全认证。在权限角色上,我们简单设计两个用户角色:USER,ADMIN。

我们设计页面的权限如下:

首页/ : 所有人可访问
登录页 /login: 所有人可访问
普通用户权限页 /httpapi, /httpsuite: 登录后的用户都可访问
管理员权限页 /httpreport : 仅管理员可访问
无权限提醒页: 当一个用户访问了其没有权限的页面,我们使用全局统一的异常处理页面提示。

1.数据库层设计:新建三张表User,Role,UserRole

对应的领域实体模型类如下:

用户表

package com.springboot.in.action.entity

import javax.persistence.{Entity, GeneratedValue, GenerationType, Id}

import scala.beans.BeanProperty

/**
* Created by jack on 2017/4/29.
*/
@Entity
class User {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@BeanProperty
var id: Integer = _
@BeanProperty
var userName: String = _
@BeanProperty
var password: String = _

}

角色表

package com.springboot.in.action.entity

import javax.persistence.{Entity, GeneratedValue, GenerationType, Id}

import scala.beans.BeanProperty

/**
* Created by jack on 2017/4/29.
*/
@Entity
class Role {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@BeanProperty
var id: Integer = _
@BeanProperty
var role: String = _

}

用户角色关联表

package com.springboot.in.action.entity

import javax.persistence.{Entity, GeneratedValue, GenerationType, Id}

import scala.beans.BeanProperty

/**
* Created by jack on 2017/4/29.
*/
@Entity
class UserRole {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@BeanProperty
var id: Integer = _
@BeanProperty
var userId: Integer = _
@BeanProperty
var roleId: Integer = _


}

为了方便测试,我们后面会写一个用户测试数据的自动生成的Bean,用来做测试数据的自动初始化工作。

2.配置Spring Security

我们首先使用Spring Security帮我们做登录、登出的处理,以及当用户未登录时只能访问: ​​http://localhost:8888/​​​ 以及 ​​http://localhost:8888/login​​ 两个页面。

同样的,我们要写一个继承WebSecurityConfigurerAdapter的配置类:

package com.springboot.in.action.security;

import com.springboot.in.action.service.LightSwordUserDetailService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
* Created by jack on 2017/4/27.
*/

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
//使用@EnableGlobalMethodSecurity(prePostEnabled = true)
// 这个注解,可以开启security的注解,我们可以在需要控制权限的方法上面使用@PreAuthorize,@PreFilter这些注解。
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

//@Autowired
//LightSwordUserDetailService lightSwordUserDetailService;

@Override
@Bean
public UserDetailsService userDetailsService() { //覆盖写userDetailsService方法 (1)
return new LightSwordUserDetailService();

}

/**
* If subclassed this will potentially override subclass configure(HttpSecurity)
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//super.configure(http);
http.csrf().disable();

http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/amchart/**",
"/bootstrap/**",
"/build/**",
"/css/**",
"/dist/**",
"/documentation/**",
"/fonts/**",
"/js/**",
"/pages/**",
"/plugins/**"
).permitAll() //默认不拦截静态资源的url pattern (2)
.anyRequest().authenticated().and()
.formLogin().loginPage("/login")// 登录url请求路径 (3)
.defaultSuccessUrl("/httpapi").permitAll().and() // 登录成功跳转路径url(4)
.logout().permitAll();

http.logout().logoutSuccessUrl("/"); // 退出默认跳转页面 (5)

}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//auth
// .inMemoryAuthentication()
// .withUser("root")
// .password("root")
// .roles("ADMIN", "USER")
// .and()
// .withUser("admin").password("admin")
// .roles("ADMIN", "USER")
// .and()
// .withUser("user").password("user")
// .roles("USER");

//AuthenticationManager使用我们的 lightSwordUserDetailService 来获取用户信息
auth.userDetailsService(userDetailsService()); // (6)
}

}

这里只做了基本的配置,其中:

(1)覆盖写userDetailsService方法,具体的LightSwordUserDetailService实现类,我们下面紧接着会讲。

(2)默认不拦截静态资源的url pattern。我们也可以用下面的WebSecurity这个方式跳过静态资源的认证

public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/resourcesDir/**");
}

(3)跳转登录页面url请求路径为/login,我们需要定义一个Controller把路径映射到login.html。代码如下

package com.springboot.in.action.security

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.{ViewControllerRegistry, WebMvcConfigurerAdapter}


/**
* Created by jack on 2017/4/30.
*/
@Configuration
class WebMvcConfig extends WebMvcConfigurerAdapter {
/**
* 统一注册纯RequestMapping跳转View的Controller
*/
override def addViewControllers(registry: ViewControllerRegistry) {
registry.addViewController("/login").setViewName("/login")
}
}

这里我们直接采用ViewControllerRegistry来注册一个纯路径映射的Controller方法。

login.html

#parse("/common/header.html")

<div class="container-fluid">

<div class="box box-success">
<div class="box-header">
<h2>LightSword自动化测试平台(<a href="http://localhost:8888/">LightSword</a>)</h2>
</div>

<div class="box-body">

<h3>登录</h3>
<form name='f' actinotallow='/login' method='POST'>
<table>
<tr>
<td>用户名:</td>
<td><input type='text' name='username' value=''></td>
</tr>
<tr>
<td>密码:</td>
<td><input type='password' name='password'/></td>
</tr>
<tr>
<td colspan='2'><input name="submit" type="submit" value="登录"/></td>
</tr>
<!--<input name="_csrf" type="hidden" value="${_csrf}"/>-->

</table>
</form>
</div>
</div>
</div>

<script>
$(function () {
$('[name=f]').focus()
})
</script>

#parse("/common/footer.html")

(4)登录成功后跳转的路径为/httpapi
(5)退出后跳转到的url为/
(6)认证鉴权信息的Bean,采用我们自定义的从数据库中获取用户信息的LightSwordUserDetailService类。

我们同样使用@EnableGlobalMethodSecurity(prePostEnabled = true)这个注解,开启security的注解,这样我们可以在需要控制权限的方法上面使用@PreAuthorize,@PreFilter这些注解。

3.自定义LightSwordUserDetailService

从数据库中获取用户信息的操作是必不可少的,我们首先来实现UserDetailsService,这个接口需要我们实现一个方法:loadUserByUsername。即从数据库中取出用户名、密码以及权限相关的信息。最后返回一个UserDetails 实现类。

代码如下:

package com.springboot.in.action.service

import java.util

import com.springboot.in.action.dao.{RoleDao, UserDao, UserRoleDao}
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.{User, UserDetails, UserDetailsService, UsernameNotFoundException}
import org.springframework.stereotype.Service
import org.springframework.util.StringUtils


/**
* Created by jack on 2017/4/29.
*/
@Service
class LightSwordUserDetailService extends UserDetailsService {

@Autowired var userRoleDao: UserRoleDao = _
@Autowired var userDao: UserDao = _
@Autowired var roleDao: RoleDao = _


override def loadUserByUsername(username: String): UserDetails = {

// val user = userDao.findByUsername(username) // 直接调用jpa自动生成的方法
val user = userDao.getUserByUsername(username)
if (user == null) throw new UsernameNotFoundException(username + " not found")

val authorities = new util.ArrayList[SimpleGrantedAuthority]
val userRoles = userRoleDao.listByUserId(user.id)

// Scala中调用java的collection类,使用scala的foreach,编译器会提示无法找到result的foreach方法。因为这里的userRoles的类型为java.util.List。若要将其转换为Scala的集合,就需要增加如下语句:
import scala.collection.JavaConversions._
for (userRole <- userRoles) {
val roleId = userRole.roleId
val roleName = roleDao.findOne(roleId).role
if (!StringUtils.isEmpty(roleName)) {
authorities.add(new SimpleGrantedAuthority(roleName))
}

System.err.println("username is " + username + ", " + roleName)
}

new User(username, user.password, authorities)
}
}

4.用户退出

我们在configure(HttpSecurity http)方法里面定义了任何权限都允许退出,

*.logout().permitAll();
http.logout().logoutSuccessUrl("/"); // 退出默认跳转页面 (4)

SpringBoot集成Security的默认退出请求是/logout , 我们在顶部导航栏加个退出功能。代码如下

<li>
<a href="/logout">
<i class="fa fa-power-off"></i>
</a>
</li>
5.配置错误处理页面

访问发生错误时,跳转到系统统一异常处理页面。

我们首先添加一个GlobalExceptionHandlerAdvice,使用@ControllerAdvice注解:

package com.springboot.in.action.advice

import org.springframework.web.bind.annotation.{ControllerAdvice, ExceptionHandler}
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.ModelAndView

/**
* Created by jack on 2017/4/27.
*/
@ControllerAdvice
class GlobalExceptionHandlerAdvice {

@ExceptionHandler(value = Array(classOf[Exception])) //表示捕捉到所有的异常,你也可以捕捉一个你自定义的异常
def exception(exception: Exception, request: WebRequest): ModelAndView = {
val modelAndView = new ModelAndView("/error") //error页面
modelAndView.addObject("errorMessage", exception.getMessage)
modelAndView.addObject("stackTrace", exception.getStackTrace)
modelAndView
}

}

其中,@ExceptionHandler(value = Array(classOf[Exception])) ,表示捕捉到所有的异常,这里你也可以捕捉一个你自定义的异常。比如说,针对安全认证的Exception,我们可以单独定义处理。此处不再赘述。感兴趣的读者,可自行尝试。

错误统一处理页面error.html

#parse("/common/header.html")
<h1>系统异常统一处理页面</h1>

<h3>异常消息: $errorMessage</h3>

<h3>异常堆栈信息:</h3>
<code>
#foreach($e in $stackTrace)
$e
#end
</code>


#parse("/common/footer.html")

6.测试运行

为了方便测试用户权限功能,我们给数据库初始化一些测试数据进去:

package com.springboot.in.action.service

import java.util.UUID
import javax.annotation.PostConstruct

import com.springboot.in.action.dao.{RoleDao, UserDao, UserRoleDao}
import com.springboot.in.action.entity.{Role, User, UserRole}
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service

/**
* Created by jack on 2017/4/29.
* 初始化测试数据
*/
@Service // 需要初始化数据时,打开注释即可。
class DataInit @Autowired()(val userDao: UserDao,
val userRoleDao: UserRoleDao,
val roleDao: RoleDao) {

@PostConstruct def dataInit(): Unit = {
val uuid = UUID.randomUUID().toString

val admin = new User
val jack = new User

admin.username = "admin_" + uuid
admin.password = "admin"

jack.username = "jack_" + uuid
jack.password = "123456"

userDao.save(admin)
userDao.save(jack)

val adminRole = new Role
val userRole = new Role

adminRole.role = "ROLE_ADMIN"
userRole.role = "ROLE_USER"

roleDao.save(adminRole)
roleDao.save(userRole)

val userRoleAdminRecord1 = new UserRole
userRoleAdminRecord1.userId = admin.id
userRoleAdminRecord1.roleId = adminRole.id
userRoleDao.save(userRoleAdminRecord1)

val userRoleAdminRecord2 = new UserRole
userRoleAdminRecord2.userId = admin.id
userRoleAdminRecord2.roleId = userRole.id
userRoleDao.save(userRoleAdminRecord2)

val userRoleJackRecord = new UserRole
userRoleJackRecord.userId = jack.id
userRoleJackRecord.roleId = userRole.id
userRoleDao.save(userRoleJackRecord)


}

}

同样的,在我们需要权限控制的页面对应的方法上添加@PreAuthorize注解,value="hasRole('ADMIN')")或"hasRole('USER')"等。

部署应用,访问​​http://localhost:8888/httpapi​​ , 我们可以看到系统自动拦截跳转到登录页面

8.6 Spring Boot集成Spring Security_登录页面_07

输入USER角色的用户名jack,密码123456,系统跳转到默认登录成功页面。我们访问无权限页面​​http://localhost:8888/httpreport​​ ,可以看出,系统拦截到无权限,跳转到了错误提示页面

8.6 Spring Boot集成Spring Security_用户名_08

技术点讲解

Spring Security 相关接口和类

  1. UserDetails 接口:作用是提供认证相关的用户的信息. 其主要的方法就是:String getPassword(); 和 String getUsername();
  2. User 类: 特指 org.springframework.security.core.userdetails 包中的 User 类。 它实现了 UserDetails 接口。
  3. UserDetailsService 接口:作用是在特定用户权限认证时,用于加载用户信息。 该接口只有一个方法,用于返回用户的信息:UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;那么,它的框架里面默认的实现类有 InMemoryUserDetailsManager,CachingUserDetailsService 和 JdbcDaoImpl,一个用于从内存中拿到用户信息,一个用于从数据库中拿到用户信息。

我们自定义LightSwordUserDetailService实现了UserDetailsService接口,从我们自己定义的数据库表里面取得用户信息来认证鉴权。

小结

本章节通过一个简单而完整的示例完成了对Web应用的登录,权限等的安全控制。完整工程源代码:

​https://github.com/EasySpringBoot/lightsword/tree/spring_security_with_db_user_role_2017.4.28​

Spring Security提供的功能还远不止于此,更多Spring Security的使用可参见【参考资料】部分。

参考资料:
0.​​​http://baike.baidu.com/item/spring%20security​​​

1.​​http://elim.iteye.com/blog/2247073​​​

3.​​https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples/spring-boot-sample-secure​​​

4.​​http://www.open-open.com/lib/view/open1464482054012.html​​​

5.​​https://github.com/EasySpringBoot/spring-security​​​

6.​​http://docs.spring.io/spring-security/site/docs/4.1.0.RELEASE/reference/htmlsingle/#jc-authentication​​​

7.​​https://github.com/pzxwhc/MineKnowContainer/issues/58​​​

8.​​http://stackoverflow.com/questions/22998731/httpsecurity-websecurity-and-authenticationmanagerbuilder​​​

9.​​https://spring.io/blog/2013/07/03/spring-security-java-config-preview-web-security/​​​

10.​​https://springcloud.cc/spring-security-zhcn.html​