SpringBoot整合Security框架

Spring Security

“万一我随便输入一个账号,再随便输入一堆密码,就登录到了马云的淘宝号呢?”:yum:

“那阿里又要向社会输入工作十年的人才了。”:happy:

简介

目标

我们之前在众多的实验中,无论虽然对业务逻辑的处理和持久层的操作,都封装在后端,无法被轻易访问。但是,要调用的时候,还是需要通过前端的控制层,以及thymeleaf页面来完成,这个时候很多操作都可以从url头直接进行,而不加以限制,这样是不完善的。

粗俗一点讲,就是我们需要登录了才能访问页面,其他的请求将通通加以限制。

概念

Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架(简单说是对访问权限进行控制嘛)。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

可以简单地认为 Spring Security 是放在用户和 Spring 应用之间的一个安全屏障, 每一个 web 请求都先要经过 Spring Security 进行 Authenticate 和 Authoration 验证。

我们现在来使用Security框架,来制作需要登录验证的网站模型。

基础配置

security的理解的使用比较繁杂,我们先把基础配置和重点分开来讲:

依赖与设置

引入pom依赖:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mybatis:
mapper-locations: classpath:/mapper/*.xml
type-aliases-package: com.example.security.pojo

spring:
datasource:
username: root
password: 123456
url: jdbc:mysql://localhost:3306/springtest?useSSL=false&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8
driver-class-name: com.mysql.cj.jdbc.Driver
tomcat:
max-idle: 10
max-wait: 10000
max-active: 50
initial-size: 5
server:
port: 8888

这里还是使用mybatis来操作持久层

mapper
1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.security.dao.UserMapper">
<select id="getUser" parameterType="String" resultType="user">
select * from t_user_roles where username =#{username}
</select>
</mapper>

mapper的设置,直接获取全部属性。

数据库

这是数据库的设置,注意roles形式是固定,必须有ROLE_ 前缀

必要的组件

这里将所有必要的组件类列出来,这里不是重点,但是很有必要。

实体类
1
2
3
4
5
6
7
8
9
10
11
12
@Data
@Alias("user")
public class User {
private Long id;

private String username;

private String password;

private String roles;

}
dao层
1
2
3
4
5
public interface UserMapper {

User getUser(String username);

}
服务层
1
2
3
4
5
6
7
8
9
10
@Service
public class UserService {

@Autowired
UserMapper userMapper;

public User getUserName(String name) {
return userMapper.getUser(name);
}
}
控制层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Controller
public class UserController {

@RequestMapping("/")
public String index(){
return "index";
}

@RequestMapping("/login")
public String login(){
return "login";
}

@RequestMapping("/mypage")
public String my(){
return "mypage";
}
@RequestMapping("/admin")
public String a(){
return "admin";
}

@RequestMapping("/logout")
public String logoutpage(HttpServletRequest request, HttpServletResponse response){
//Authentication 是一个接口,用来表示用户认证信息
Authentication auth= SecurityContextHolder.getContext().getAuthentication();
if (auth!=null){
new SecurityContextLogoutHandler().logout(request,response,auth);
}

//重定向到 login
return "redirect:/login?logout";

}

}

这里列出了控制层要接收的所有映射,现在暂时不说明他们各自的作用,待会会结合Security的各个配置一并讲解出来。

Security——登录和注销

这里开始讲述,Security是如何将它们整合起来的。大家可能会发现,上面的Controller层代码并没有Service层的注入,那么控制层是如何从数据库获取验证的呢?这里由下而上,从Service的注入开始。

UserDetailsService

Service层的注入在了一个接入了UserDetailsService接口的类里面。而这个类通过重写UserDetailsService的方法,来获取到数据库的用户信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
public class MyUserDetailService implements UserDetailsService {

@Autowired
UserService userService;


@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
System.out.println("通过用户名加载用户");
com.example.security.pojo.User user = userService.getUserName(s);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}

return new User(user.getUsername() ,user.getPassword(),
createAuthority(user.getRoles()));

}

//这里是将数据库的角色分割,构造GrantedAuthority
private List<SimpleGrantedAuthority> createAuthority(String roles) {
String[] roleArray = roles.split(",");
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
for (String role : roleArray) {
authorityList.add(new SimpleGrantedAuthority(role));
}
return authorityList;
}
}

这里重写了loadUserByUsername方法,这个方法会返回一个User类,我们会使用服务层的API,从数据库读取数据,让我们的user获得各项信息,在结合自己定义的方法,把我们的Roles划分为一个ArrayList列表。再使用Security的User类去重新构造它,并且返回。

注意,返回的这个User类是属于:

1
import org.springframework.security.core.userdetails.User;

这里在下犯了一个错误,就是不应该直接把实体类的名称直接定义为User,这样导致两个User类产生了冲突,所以在loadUserByUsername方法开头的user实例,使用地址的方式去声明。

那么这个类的方法又会被谁调用呢?

WebSecurityConfigurerAdapter

这个方法会被继承了WebSecurityConfigurerAdapter的类所调用。我们首先要继承WebSecurityConfigurerAdapter,并重写两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

@Autowired
DataSource dataSource;

@Autowired
MyUserDetailService myUserDetailService;

@Autowired
AuthenticationSuccessHandler authenticationSuccessHandler;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/", "/index").permitAll()//允许/、/index的访问
.antMatchers("/mypage/**").hasAnyRole("USER")//用户USER角色的用户访问有关/mypage下面的所有
.antMatchers("/admin/**").hasAnyRole("ADMIN")//同上
.anyRequest().authenticated()//其它所有访问都拦截
.and()
.formLogin()//添加登陆
.loginPage("/login").permitAll()//登陆页面“/login"允许访问
.defaultSuccessUrl("/mypage")//成功默认跳转 url
.usernameParameter("user")
.passwordParameter("password")
.permitAll()
.and()
.logout()
.permitAll()
//设置注销操作
//所有用户都可以访问(可以使用注销)
.and()
.exceptionHandling().accessDeniedPage("/error");


}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

auth
.userDetailsService(myUserDetailService)
.passwordEncoder(new MyPassWordEncoder());
}
}

在重写了configure(AuthenticationManagerBuilder auth)方法后,调用auth的注册,我们就能把用户的信息注册到整个Servelet的上下文中,使其全局存在,注意这里的MyPassWordEncoder:

1
2
3
4
5
6
7
8
9
10
11
public class MyPassWordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}

@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
}

是需要重写PasswordEncoder去实现的,这是为了完成Security的密码加密功能,这里暂时不做延伸。

然后重写的configure(HttpSecurity http)方法,这个方法就是用来拦截请求和注册的,非常的关键。

一开始,便使用了authorizeRequests,来配置和定义请求,使用antMatchers来定义页面和权限的信息。

.and()的作用是连接表示还有事务需要配置

使用.formLogin()去定义登录页面,对于需要权限的请求,都需要跳转到某个url进行登录操作,使用loginPage(“/login”),表示url,permitAll()表示全部需要权限的页面

最后的logout()表示注销的页面,这里默认为logout,但注销操作实际上要根据业务的需要去定义,一般不会直接定位到某个页面。

.exceptionHandling().accessDeniedPage(“/error”);表示出现权限不足时,需要跳转的页面。

这里就需要重新提起Controller层的操作了:

1
2
3
4
@RequestMapping("/login")
public String login(){
return "login";
}

虽然我们定义了跳转页面,但是我们仍然需要对/login请求进行重新规划,在defaultSuccessUrl中,定义了登录成功后会跳转的URL,这就表示了,如果登录成功,则跳转到相应的URL页面,否则,仍然返回login页面。

注销的实现

重新看回Controller层的/logout页面。

1
2
3
4
5
6
7
8
9
10
11
12
@RequestMapping("/logout")
public String logoutpage(HttpServletRequest request, HttpServletResponse response){
//Authentication 是一个接口,用来表示用户认证信息
Authentication auth= SecurityContextHolder.getContext().getAuthentication();
if (auth!=null){
new SecurityContextLogoutHandler().logout(request,response,auth);
}

//重定向到 login
return "redirect:/login?logout";

}

这里我们接受到了一个请求,使用(HttpServletRequest request, HttpServletResponse response),去处理这请求。

首先看到SecurityContextHolder.getContext().getAuthentication(); 它获取了我们存在于服务器中的上下文,同时又获取到了该用户的名字,如果该用户存在,则new一个新的用户去相应logout页面,最后再重定向回来。重定向在thymeleaf中也有所提及,是重新访问url页面并执行方法的意思。

测试

开始测试,首先需要HTML页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>登陆页面</h1>

<div th:if="${param.error}">
密码错误
</div>
<div th:if="${param.logout}">
您已注销
</div>

<form th:action="@{/login}" action="" method="post">
用户名:<input name="user"/><br>
密码:<input name="password"><br/>
<input type="checkbox" name="remember-me">记住我<br/>
<input type="submit" value="登陆">
</form>


</body>
</html>
登录界面

这个就是登录界面.。

权限页面

当我们登录了张三这个用户的身份后,将自动跳转到mypage页面

再尝试去访问admin页面。

于是点击退出

然后再次用管理员的账号去登录管理员界面

完成!余下HTML页面会在文章最后的源码连接中放出。

Security——记住我

在WebSecurityConfigurerAdapter,选择记住我,但是这个.rememberMe(),也是有着许许多多的层级关系的。

Cookies

我们直接配置中使用:

1
http.rememberMe();

开启的rememberMe()功能,但是它是存储于一个全局的Cookies中的,它会存在一段时间,在这段时间中,即使你切到其他页面,再切回来,也不用登录,但是一旦清除了Cookies,那就必须重新登录了。

同时,除了通过自己去编辑注销之外,还可以使用Security中自带的注销方法,它还可以自定义注销成功跳转的目录。:

1
http.logout().logoutSuccessUrl("/");

同时,在控制层中注释掉logout的映射关系,并在html页面上改成:

1
2
3
4
<h3>退出</h3>
<form th:action="@{/logout}" method="post" >
<input type="submit" value="注销">
</form>

它就会提交一个logout请求,而Security就会自动识别到这个请求,来完成我们的注销功能

可以在浏览器中按F12,打开application 的cookies页面,查看jsession信息。

自定义Cookies

我们还可以使用自定义Cookies去存储用户信息,使我们在关闭浏览器后,还缓存固定的时间。

1
2
3
4
5
6
http.authorizeRequests()                
.and()
.rememberMe()
.key("uniqueAndSecret")
.rememberMeCookieName("remember-me")
.tokenValiditySeconds(60);

重新添加这段代码后,我们就将用户信息缓存在了Cookies中。

使用.key(“uniqueAndSecret”),指定缓存的键名,.rememberMeCookieName(“remember-me”)指定Cookies名。

.tokenValiditySeconds(60);则是指定Cookies时间,这里使用60秒来进行测试,我们可以在测试后发现,它确实在关闭浏览器后,仍然仍然存在。

Token

但是使用Cookies还是有着弊端,那就是我们再使用什么清理垃圾软件清理缓存的时候,将全部被清除掉,或者对于不支持缓存的浏览器而言,也是极其致命的,于是,我们可以将数据放到数据库中。这样就没有问题了。

修改配置
1
2
3
4
5
6
http.authorizeRequests()
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(60)
.userDetailsService(myUserDetailService);

增加这项配置,注意 .tokenRepository 将会注册一个处理方法:

Bean
1
2
3
4
5
6
7
8
9
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
jdbcTokenRepository.setDataSource(dataSource);
// 第一次启动的时候自动建表
//jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}

这里配置了PersistentTokenRepository,也就是Token的处理方法。当我们开启了这个后,可以访问页面,这个时候便可以再去观察数据库,你会发现:

这个便是我们的登录的信息,它会被存储在数据库里,它也会再时间过期后,将用户登录信息给无效化。

Security——定制跳转

我们也可以在登录成功后,定制跳转页面,使得我们拥有admin权限的用户直接跳转到后台管理页面,而user权限的则跳转默认的主页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Autowired
AuthenticationSuccessHandler authenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
System.out.println("AppSecurityConfigurer()调用-------");
http.authorizeRequests()
//spring-scurity 5.0之后需要过滤静态资源

.antMatchers("/login","/css/**","/js/**","/img/**").permitAll()
//指定用户可以访问的多个url模式。
// 特别的,任何用户可以访问以"/resources"开头的url资源,或者等于"/signup"或about

.antMatchers("/","/home").hasRole("USER")
//任何以"/home"开头的请求限制用户具有 "ROLE_USER"角色。
// 你可能已经注意的,尽管我们调用的hasRole方法,但是不用传入"ROLE_"前缀


.antMatchers("/admin/**").hasAnyRole("ADMIN","DBA")
//这个是拥有其中一个权限都能使用

.anyRequest().authenticated()
//任何没有匹配上的其他的url请求,需要用户被验证。

.and()
//拼接

.formLogin().loginPage("/login")
//开始设置登录操作
//设置登录页面的访问地址

.successHandler(authenticationSuccessHandler)
//设置了一个认证处理,登录成功后不同用户需要跳转到不同的页面
//以此认证,视为通行证

.usernameParameter("loginName").passwordParameter("password")
//登录时接受传递的参数 loginName 和password
//注意大小写

.and()
.logout().permitAll()
//设置注销操作
//所有用户都可以访问(可以使用注销)

.and()
.exceptionHandling().accessDeniedPage("/accessDenied");
//指定异常处理页面
//特别是在没有权限的时候使用发生的错误
}

这便是我们重新写的http处理。注释写完整了各个位置的含义。

以下是AuthenticationSuccessHandler 处理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@Component
public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

//spring security 通过RedirectStrategy 对象负责所有重定向事务
private RedirectStrategy redirectStrategy=new DefaultRedirectStrategy();

/*
重写 handle 方法,方法中通过 RedirectStrategy 对象重定向到指定的url
*/

@Override
protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//通过determineTargetURL方法返回需要跳转的URL
String targetUrl=determineTargetUrl(authentication);
//重定向请求指定的URL
redirectStrategy.sendRedirect(request,response,targetUrl);
}
/*
从Authentication 对象中提取当前登录用户的角色,并根据其角色返回适当的URL
*/

protected String determineTargetUrl(Authentication authentication) {
String url="";

// 获取当前登录用户的角色权限到集合 authentication
Collection<? extends GrantedAuthority> authorities=authentication.getAuthorities();
//Collection是最基本的集合接口,
// 一个Collection代表一组Object,即Collection的元素(Elements)。
// 一些Collection允许相同的元素而另一些不行。一些能排序而另一些不行。
// Java SDK不提供直接继承自Collection的类,
// Java SDK提供的类都是继承自Collection的“子接口”如List和Set。

List<String> roles=new ArrayList<String>();

//将角色名称添加到List集合
for (GrantedAuthority a:authorities)
{
roles.add(a.getAuthority());
}
//判断不同角色跳转到不同的URL
if(isAdmin(roles))
{
url="/admin";
}else if (isUser(roles)){
url="/home";
}else {url="/accessDenied";
}
System.out.println("url="+url);
return url;
}

private boolean isUser(List<String> roles) {
if (roles.contains("ROLE_USER")){
return true;
}
return false;
}

private boolean isAdmin(List<String> roles) {
if (roles.contains("ROLE_ADMIN")){
return true;
}
return false;
}
//set 和 get

@Override
public RedirectStrategy getRedirectStrategy() {
return redirectStrategy;
}

@Override
public void setRedirectStrategy(RedirectStrategy redirectStrategy) {
this.redirectStrategy = redirectStrategy;
}
}

源码地址:

https://github.com/Antarctica000/SpringBoot/tree/master/security