Spring Security

Spring Security


如果你只是想实现一个简单的 web 应用,shiro 更加的轻量级,学习成本也更低,如果你正在开发一个分布式的、微服务的、或者与 Spring Cloud 系列框架深度集成的项目,建议使用 Spring Security。

用户权限模型,分别对应:用户表、角色表、权限表、用户角色关系表,角色权限对应表。
Spring Security 有两个关键字 Authentication(认证) 和 Authorization(授权)

准备工作

1
2
3
4
5
<!-- 权限管理组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

httpBasic

SecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 添加自己自定义配置

@Override
protected void configure(HttpSecurity http) throws Exception {

// 1. httpBasic 登录模式可以被轻松破解
/**
* 实际上是通过 base64 将用户名密码加密,如果通过工具进行网络劫持,可以把用户名密码还原。破解。
* 必须通过 Authorization 请求头 加入的一个 Basic:{用户名密码} 串。
* 服务端接受到请求,会去 Authorization 中提取字符串然后登录。
*/
http.httpBasic()
.and()
.authorizeRequests()
.anyRequest()
.authenticated();
}
}

可以被破解,不安全。

formLogin

SecurityConfig.java

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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 2. formLogin 模式的登录认证 跨站防御 scrf()
http.csrf().disable().formLogin()
.loginPage("/login.html") // 登录页面
// .usernameParameter("account") // 换名字
.loginProcessingUrl("/login") // 登录的动态 url /login
.defaultSuccessUrl("/index") // 登录成功后跳转的 url /index
.and().authorizeRequests()
.antMatchers("/login.html", "/login", "/captcha").permitAll()
// .antMatchers("/xxx") // 需要对外暴露的资源路径
// .hasAnyRole(AdminRole.USER, AdminRole.ADMIN)
// .antMatchers("/xx") // 下面两个效果一样。
// .hasAnyRole("admin")
// .hasAnyAuthority(AdminRole.ADMIN)
.anyRequest()
.authenticated();
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/login.html")
.sessionFixation().migrateSession(); // 迁移 session 重新复制一份 sessionId 登录后身份证不变
// .sessionFixation().changeSessionId() // 重新登录 sessionId 会改变,但是还是原来的 session
// .sessionFixation().newSession() // 新建session
// .sessionFixation().none() // 不做任何操作,原来的 sessionId 也能登录
;
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("123456"))
.roles("user")
.and()
.withUser(AdminRole.ADMIN)
.password(passwordEncoder().encode("123456"))
.roles(AdminRole.ADMIN)
// .authorities("sys:log")
.and()
.passwordEncoder(passwordEncoder()); // 配置 BCrypt 加密
}

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

@Override
public void configure(WebSecurity web) throws Exception {
// 将项目中静态资源路径开放出来
web.ignoring()
.antMatchers("/css/**", "/fonts/**", "/images/**", "/js/**", "/layui/**");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<form class="layui-form" id="loginForm" action="/admin/login" method="post">
<div class="layui-form-item">
<input type="text" name="username" required lay-verify="title" placeholder="用户名" autocomplete="off" class="layui-input">
</div>
<div class="layui-form-item">
<input type="password" name="password" required lay-verify="password" placeholder="密码" autocomplete="off" class="layui-input">
</div>
<div class="layui-form-item">
<div class="layui-inline">
<input type="text" name="captchaCode" required placeholder="验证码" autocomplete="off" class="layui-input">
</div>
<div class="layui-inline">
<img class="verifyImg" src="/admin/captcha" onclick="captchaCodeResult()"/>
</div>
</div>
<div class="layui-form-item m-login-btn">
<div class="layui-inline">
<button class="layui-btn layui-btn-normal submitBtn" type="submit">登录</button>
</div>
<div class="layui-inline">
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>

SpringSecurity基本原理


基于过滤器 filter,核心是FilterChains

绿色部分一系列过滤器来认证 http 请求,哪个过滤器可以通过就会放行。请求时,会把认证通过的认证主体和请求存入 SecurityContext 中,在把 SecurityContext 存入 session 中,在下一次请求的时候,就会直接从 session 中取出认证主体,不会再次进行认证。

上图只是初步了解主体过程。下面是源码

  1. 请求到达 AuthenticationFilter
  2. 用用户名和密码构建一个令牌 Token
  3. AuthenticationManager 拿到 Token,
  4. 通过对应的 AuthenticationProvider 列表中的某个 provider 认证,一般是用 DaoAuthenticationProvider
  5. 将 Token 中的 password 加密。
  6. 通过 UserDetailsService 从数据库中查询出 User
  7. 78910进行认证通过 FilterChains 将 认证主体 Authentication(Principal + Authorities) 保存到 SecurityContext 中,在把 SecurityContext 存入 session 中。

AuthenticaionProvider

AuthenticaionProvider 接口是用于认证的,可以通过实现这个接口指定我们自己的认证逻辑,它的实现类有很多,默认是 JaasAuthenticationProvider Java Authentication and Authorization Service (JAAS)

AccessDecisionManager

AccessDecisionManager 用于访问控制, 它决定用户是否可以访问某个资源,实现这个接口定制我们自己的授权逻辑。

AccessDecisionVoter

AccessDecisionVoter 投票器,在授权的时候通过投票的方式来决定用户是否可以访问,投票规则。

UserDetailsService

UserDetailsService 用于加载特定用户信息的,只有一个接口通过制定的用户名去查询。

UserDetails

UserDetails代表用户信息,即主体,相当于Shiro中的Subject。User是它的一个实现。

Security 在非分布式应用中的使用


需求:结合前端 cookie、session 开发一个具有记住我、同一用户只有一个客户端有效登录的 admin 管理平台。下图为登录验证请求的流程

过滤器在认证之后,对认证结果进行审查,如果认证成功,会执行 AuthenticationSuccessHandler,如果失败会执行 AuthenticationFailureHandler 接口。所以我们需要自定义类 实现这两个接口。

这里我采用了 jsonwebtoken 框架作为用户唯一标识

1
2
3
4
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>

登录步骤

首先配置文件如下:

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
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsService userDetailsService;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;

@Autowired
private CaptchaCodeFilter captchaCodeFilter;
@Autowired
private AuthenticationTokenFilter tokenFilter;
@Autowired
private DataSource dataSource;

@Override
protected void configure(HttpSecurity http) throws Exception {
// 2. formLogin 模式的登录认证 跨站防御 scrf()
http.csrf().disable()
.headers().frameOptions().sameOrigin().and()
.formLogin().loginPage("/login.html").successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).and()
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).and()
.authorizeRequests()
// options 请求全部放行
// .antMatchers("/**").authenticated()
.antMatchers("/*.html", "/captchaCode").permitAll()
.anyRequest()
.authenticated().and()
.logout().logoutSuccessHandler(logoutSuccessHandler);
;

// 使用自定义的 token 过滤器验证 请求的 token 是否合法
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(captchaCodeFilter, AuthenticationTokenFilter.class);
http.headers().cacheControl();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 校验用户
auth.userDetailsService(userDetailsService).passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
}

@Override
public boolean matches(CharSequence charSequence, String s) {
String encode = DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
boolean res = s.equals(encode);
return res;
}
});
}

@Override
public void configure(WebSecurity web) throws Exception {
// 将项目中静态资源路径开放出来
web.ignoring()
.antMatchers("/css/**", "/fonts/**", "/images/**", "/js/**", "/layui/**");
}
}

如果你的应用是无状态的前后端分离应用,可以把 sessionCreationPolicy 设置成 STATELESS。captchaCodeFilter 和 tokenFilter 分别为过滤器,用来做验证码认证和登录认证。

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
@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {

@Autowired
AdminAuthenticationFailureHandler failureHandler;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if ("/admin/login.html".equalsIgnoreCase(request.getRequestURI())
&& "post".equalsIgnoreCase(request.getMethod())) {

try {
checkCaptchaCode(request, response);
} catch (AdminAuthenticationException e) {
failureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
chain.doFilter(request, response);
}

private void checkCaptchaCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException, AdminAuthenticationException {

String randomCode = request.getParameter("captchaCode");
if (randomCode == null) return;

// 校验图片验证码
CaptchaCode captchaCode = (CaptchaCode) SessionUtil.getSessionAttribute(SessionKey.CAPTCHACODE, request);

if (captchaCode == null || captchaCode.getCode() == null)
throw new AdminAuthenticationException(AMError.CAPTCHACODE_NOT_EXIST);

if (captchaCode.isExpired()) {
SessionUtil.removeSessionAttribute(SessionKey.CAPTCHACODE, request);
throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_EXPIRED);
}
if (StringUtil.isEmpty(randomCode)) throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_EMPTY);

if (!captchaCode.getCode().equalsIgnoreCase(randomCode)) throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_WRONG);
}
}
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
@Component
public class AuthenticationTokenFilter extends OncePerRequestFilter {

@Autowired
private AdminUserDetailsService userDetailsService;
@Autowired
AdminAuthenticationFailureHandler failureHandler;
@Autowired
private TokenUtil tokenUtil;
@Autowired
private AdminUserMapper userMapper;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String spath = request.getServletPath();
// 不需要过滤的url
String[] urls = {"/login", ".html", "/layui/", "/js/", "/css/", "/fonts/", "/images/", "/captchaCode"};
boolean staticPath = StringUtil.isStaticPath(urls, spath);
if (staticPath) {
chain.doFilter(request, response);
return;
}

// 是否有 token
String token = request.getHeader(Const.HEADER_STRING);
if (StringUtil.isEmpty(token)) {
chain.doFilter(request, response);
return;
}

// 如果 token 是非法的,或者已经被校验过
String username = tokenUtil.getUsernameFromToken(token);
if (username == null) {
chain.doFilter(request, response);
return;
}

try {
// 如果有 token,说明用户登录过,
AdminUser user = userMapper.selectByAccountRoleNull(username);
if (StringUtil.isNotEmpty(user.getToken())) {
// 但是两个 token 不相等,说明被踢下线
if (!user.getToken().equals(token))
throw new AdminAuthenticationException(AMError.ACCOUNT_DISABLE_LOGIN);
// token 过期
Integer minDiff = DateUtil.minDiff(DateUtil.str2Time(user.getLoginAt()), new Date());
if (minDiff >= Const.LOGIN_EXPIRED_TIME)
throw new AdminAuthenticationException(AMError.ACCOUNT_EXPIRE_LOGIN);
}
} catch (AdminAuthenticationException e) {
failureHandler.onAuthenticationFailure(request, response, e);
return;
}

// 保存了认证信息
if (SecurityContextHolder.getContext().getAuthentication() != null) {
chain.doFilter(request, response);
return;
}

// 无状态时,手动认证
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (tokenUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}

配置文件和过滤器准备好后,先来说一下思路吧:

  • 用户执行登录操作,如果验证码、密码、用户名输入都正确会来到下面登陆成功后的处理器
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
@Component
public class AdminAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

@Value("${phoneacce.admin.loginType}")
private String loginType;
@Autowired
private AdminUserMapper userMapper;
@Autowired
private TokenUtil tokenUtil;

@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {

AdminUserDetails principal = (AdminUserDetails) authentication.getPrincipal();
String token = tokenUtil.generateToken((UserDetails) principal);
String parameter = request.getParameter("remember-me");

AdminUser updateUser = new AdminUser();
AdminUser user = principal.getAdminUser();

if (parameter != null && parameter.equals("on")) {
// 记住密码
updateUser.setId(user.getId());
updateUser.setToken(token);
CookieUtil.addCookie(response, "token", token);
} else {
// 不记住密码
updateUser.setId(user.getId());
updateUser.setToken("");
}
updateUser.setLoginAt(DateUtil.getCurDateStrMiao_());
userMapper.update(updateUser);
JsonResult jsonResult = new JsonResult();
response.setContentType("application/json; charset=UTF-8");
response.getWriter().print(JSON.toJSONString(jsonResult.success(token, "登录成功")));
}

当然 security 框架帮我们做了登录认证。此时把用户登录凭证 token 返回给前端,存入 sessionStorage,如果选择了记住密码,会把 token 也存入 cookie 中,设置一个过期时间,同时把 token 存入数据库,如果不记住密码,就将 token 清空。不记住我的模式下,用户的免登录状态认证周期是从打开浏览器到关闭浏览器。

前端执行逻辑:访问接口时,先从本地的 sessionStorage 中查找 token,如果 token 为空,则说明是第一次登录,则再去查看 cookie 中是否有值。如果 cookie 中有 token ,说明用户选择了记住我选项,

此时,当用户访问其他接口时,会来到 AuthenticationTokenFilter。执行校验。具体步骤见 AuthenticationTokenFilter 中的注释。

解决访问需要认证的资源时直接报302错误页面不跳转登录


这里需要提一下的是,这种配置在前后端不分的登录中是没有问题的,在前后端分离的登录中,这种配置就有问题了。我举个简单的例子,例如我想访问 /hello 接口,但是这个接口需要登录之后才能访问,我现在没有登录就直接去访问这个接口了,那么系统会给我返回 302,让我去登录页面,在前后端分离中,我的后端一般是没有登录页面的,也就是说,当我没有登录直接去访问 /hello 这个接口的时候,我会看到上面这段 JSON 字符串。在前后端分离开发中,这个看起来没问题(后端不再做页面跳转,无论发生什么都是返回 JSON)。
但是问题就出在这里,系统默认的跳转是一个重定向,就是说当你访问 /hello 的时候,服务端会给浏览器返回 302,同时响应头中有一个 Location 字段,它的值为 http://localhost:8081/login ,也就是告诉浏览器你去访问 http://localhost:8081/login 地址吧。
浏览器收到指令之后,就会直接去访问 http://localhost:8081/login 地址,如果此时是开发环境并且请求还是 Ajax 请求,就会发生跨域。因为前后端分离开发中,前端我们一般在 NodeJS 上启动,然后前端的所有请求通过 NodeJS 做请求转发,现在服务端直接把请求地址告诉浏览器了,浏览器就会直接去访问 http://localhost:8081/login 了,而不会做请求转发了,因此就发生了跨域问题。

如果我们的 Spring Security 在用户未认证时访问一个认证后才能访问的资源,不给用户重定向,而是直接返回一个 Json,告诉用户这个请求认证后才能发起。需要自定义 AuthencationEntryPoint 类。

从注释中看出,这个类的作用是决定到底是重定向还是要 forward,默认是重定向。所以重写这个方法,返回 Json 即可。

1
2
3
4
5
6
7
8
9
10
11
@Component
public class AdminAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
JsonResult jsonResult = new JsonResult();
response.getWriter().print(JSON.toJSONString(jsonResult.failed(AMError.ACCOUNT_NOT_LOGIN)));
}
}

在执行登出操作时,需要清空数据库 token, 浏览器的 cookie 字段即可。

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
@Component
public class AdminLogoutSuccessHandler implements LogoutSuccessHandler {

@Autowired
private AdminUserMapper userMapper;
@Autowired
private TokenUtil tokenUtil;

@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication)
throws IOException, ServletException {
response.setContentType("application/json; charset=UTF-8");
String token = request.getHeader(Const.HEADER_STRING);
JsonResult jsonResult = new JsonResult();

CookieUtil.deleteCookie("token", request, response);

// 从数据库中删除 token
String username = tokenUtil.getUsernameFromToken(token);
if (StringUtil.isEmpty(username)) {
response.getWriter().print(JSON.toJSONString(jsonResult.failed("您的浏览器可能遭到劫持")));
return;
}

// 数据库中清除 token
AdminUser updateUser = new AdminUser();
updateUser.setAccount(username);
updateUser.setToken("");
userMapper.updateByAccount(updateUser);

response.getWriter().print(JSON.toJSONString(jsonResult.success("退出成功")));
}
}

# 常用的权限表达式



表达式函数 描述
hasRole([role]) 用户拥有指定的角色时返回 true(Spring security 默认会带有 ROLE_前缀),去除前缀参考 Remove the ROLE_
hasAnyRole(role1, role2) 用户拥有任意一个指定的角色时返回 true
hasAuthority([authority]) 拥有某资源的访问权限时返回 ture
hasAnyAuthority([auth1, auth2]) 拥有某些资源其中部分资源的访问权限时返回 true
permitAll 永远返回 true
denyAll 永远返回 false
anonymous 当前用户时 anonymous 时返回 true
rememberMe 当前用户是 rememberMe 用户返回 true
authentication 当前登录用户的 authentication 对象
fullAuthenticated 当前用户既不是 anonymous 也不是 rememberMe 用户时返回 true
hasIpAddress(‘192.168.1.0/24’) 请求发送的 IP 匹配时返回 true

方法级别的安全控制


开启开关:@EnableGlobalMethodSecurity(prePostEnabled = true)

1
2
3
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  • @PreAuthorize
  • @PreFilter
  • @PostAuthorize
  • @PostFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 必须是 admin 角色才能访问
@PreAuthorize("hasRole('admin')")
public List<permission> findAll() {}

// 方法执行之后检查值是否匹配,如果不匹配则会返回权限不足
@PostAuthorize("returnObject.name == authentication.name")
public PersonDemo findOne() { return new Persion(name) }

// 前置过滤 满足规则的参数才能被传到方法内
@PreFilter(filterTarget="ids", value="filterObject % 2 == 0")
public void delete(List<Integer> ids, List<String> usernames) {

}


// 针对返回值进行过滤
@PostFilter("filterObject.name == authentication.name")
public List<PersionDemo> findAll() {

}
文章作者: Ammar
文章链接: http://lizhaoloveit.cn/2020/05/28/SpringSecurity/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Ammar's Blog
打赏
  • 微信
  • 支付宝

评论