SpringBoot前后端分离项目,集成Spring Sec

本文讲解使用SpringBoot版本:2.2.6.RELEASE,Spring Security版本:5.2.2.RELEASE

Java流行的安全框架有两种Apache Shiro和Spring Security,其中Shiro对于前后端分离项目不是很友好,最终选用了Spring Security。SpringBoot提供了官方的spring-boot-starter-security,能够方便的集成到SpringBoot项目中,但是企业级的使用上,还是需要稍微改造下,本文实现了如下功能:

  • 匿名用户访问无权限资源时的异常处理
  • 登录用户是否有权限访问资源
  • 基于redis的分布式session共享
  • session超时的处理
  • 限制同一账号同时登录最大用户数(顶号)
  • 登录成功和失败后返回json
  • 同时支持3种token存放位置:cookie,http header,request parameter

快速使用,引入依赖

1
2
3
4
5
6
7
8
9
10
复制代码<!--spring security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- spring session redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

spring-boot-starter-security用于集成spring security,spring-session-data-redis集成了redis和spring-session。

定制化接入Spring Security

使用Spring Security为的就是写最少的代码,实现更多的功能,在定制化Spring Security,核心思路就是:重写某个功能,然后配置。

  • 比如你要查自己的用户表做登录,那就实现UserDetailsService接口;
  • 比如前后端分离项目,登录成功和失败后返回json,那就实现AuthenticationFailureHandler/AuthenticationSuccessHandler接口;
  • 比如扩展token存放位置,那就实现HttpSessionIdResolver接口;
  • 等等…

最后,将上述做的更改配置到security里。套路就是这个套路,下边咱们实战一下。

Don’t bb, show me code.

1. 处理匿名用户无权访问

实现AuthenticationEntryPoint接口,可以处理匿名用户访问无权限资源时的异常,如下:

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
复制代码@Slf4j
@Component
public class AnonymousAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.warn("用户需要登录,访问[{}]失败,AuthenticationException={}", request.getRequestURI(), e);

ServletUtils.render(request, response, RestResponse.fail(ResponseCode.USER_NEED_LOGIN));
}
}

public class ServletUtils {

/**
* 渲染到客户端
*
* @param object 待渲染的实体类,会自动转为json
*/
public static void render(HttpServletRequest request, HttpServletResponse response, Object object) throws IOException {
// 允许跨域
response.setHeader("Access-Control-Allow-Origin", "*");
// 允许自定义请求头token(允许head跨域)
response.setHeader("Access-Control-Allow-Headers", "token, Accept, Origin, X-Requested-With, Content-Type, Last-Modified");
response.setHeader("Content-type", "application/json;charset=UTF-8");

response.getWriter().print(JSONUtil.toJsonStr(object));
}
}

需要注意的是,当程序出现异常错误时(比如500),也会进入到commence方法中。

2. 基于数据库的用户登录认证逻辑

从数据库中查出登录用户的信息(如密码)、角色、权限等,然后返回一个UserDetails类型的实体,security会自动根据密码和用户相关状态(是否锁定、是否启停、是否过期等)判断用户登录成功或者失败。

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
复制代码@Slf4j
@Component
public class DefaultUserDetailsService implements UserDetailsService {

@Autowired
private SystemService systemService;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StrUtil.isBlank(username)) {
log.info("登录用户:{} 不存在", username);
throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
}

// 查出密码
UserVO userVO = systemService.loadUserByUsername(username);
if (ObjectUtil.isNull(userVO) || StrUtil.isBlank(userVO.getUserId())) {
log.info("登录用户:{} 不存在", username);
throw new UsernameNotFoundException("登录用户:" + username + " 不存在");
}
return new LoginUser(userVO, IpUtils.getIpAddr(ServletUtils.getRequest()), LocalDateTime.now(), LoginType.PASSWORD);
}

}

/**
* 扩展用户信息
*
* @author songyinyin
* @date 2020/3/14 下午 05:29
*/
@Data
public class LoginUser implements UserDetails, CredentialsContainer {

private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

/**
* 用户
*/
private UserVO user;

/**
* 登录ip
*/
private String loginIp;

/**
* 登录时间
*/
private LocalDateTime loginTime;

/**
* 登陆类型
*/
private LoginType loginType;

public LoginUser() {
}

public LoginUser(UserVO user, String loginIp, LocalDateTime loginTime, LoginType loginType) {
this.user = user;
this.loginIp = loginIp;
this.loginTime = loginTime;
this.loginType = loginType;
}

public LoginUser(UserVO user, String loginIp, LocalDateTime loginTime, String loginType) {
this.user = user;
this.loginIp = loginIp;
this.loginTime = loginTime;
this.loginType = LoginType.valueOf(loginType);
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}

@Override
public String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getUserName();
}

/**
* 账户是否未过期,过期无法验证
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 指定用户是否解锁,锁定的用户无法进行身份验证
* <p>
* 密码锁定
* </p>
*/
@Override
public boolean isAccountNonLocked() {
return ObjectUtil.equal(user.getPwdLockFlag(), LockFlag.UN_LOCKED);
}

/**
* 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 用户是否被启用或禁用。禁用的用户无法进行身份验证。
*/
@Override
public boolean isEnabled() {
return ObjectUtil.equal(user.getStopFlag(), StopFlag.ENABLE);
}

/**
* 认证完成后,擦除密码
*/
@Override
public void eraseCredentials() {
user.setPassword(null);
}
}

同时LoginUser还实现了CredentialsContainer接口,用户认证成功后,擦除密码,然后返给前端。

3. 登录成功的处理

登录成功后,一般要记录登录日志,然后把认证之后的用户authentication返给前端

1
2
3
4
5
6
7
8
9
10
11
复制代码@Slf4j
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

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

// TODO 登录成功 记录日志
ServletUtils.render(request, response, RestResponse.success(authentication));
}
}

4. 登录失败的处理

登录失败后,可以根据不同的AuthenticationException,来区分是为什么登录失败,这里需要有日志打印,然后根据业务需求,返回信息给前端。比如要求是无论什么错误,都返回登录失败,这里的示例是进行了登录失败的区分。

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
复制代码@Slf4j
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
RestResponse result;
String username = UserUtil.loginUsername(request);
if (e instanceof AccountExpiredException) {
// 账号过期
log.info("[登录失败] - 用户[{}]账号过期", username);
result = RestResponse.build(ResponseCode.USER_ACCOUNT_EXPIRED);

} else if (e instanceof BadCredentialsException) {
// 密码错误
log.info("[登录失败] - 用户[{}]密码错误", username);
result = RestResponse.build(ResponseCode.USER_PASSWORD_ERROR);

} else if (e instanceof CredentialsExpiredException) {
// 密码过期
log.info("[登录失败] - 用户[{}]密码过期", username);
result = RestResponse.build(ResponseCode.USER_PASSWORD_EXPIRED);

} else if (e instanceof DisabledException) {
// 用户被禁用
log.info("[登录失败] - 用户[{}]被禁用", username);
result = RestResponse.build(ResponseCode.USER_DISABLED);

} else if (e instanceof LockedException) {
// 用户被锁定
log.info("[登录失败] - 用户[{}]被锁定", username);
result = RestResponse.build(ResponseCode.USER_LOCKED);

} else if (e instanceof InternalAuthenticationServiceException) {
// 内部错误
log.error(String.format("[登录失败] - [%s]内部错误", username), e);
result = RestResponse.fail(ResponseCode.USER_LOGIN_FAIL);

} else {
// 其他错误
log.error(String.format("[登录失败] - [%s]其他错误", username), e);
result = RestResponse.fail(ResponseCode.USER_LOGIN_FAIL);
}
// TODO 登录失败 记录日志
ServletUtils.render(request, response, result);
}
}

5. 退出登录的回调

和登录成功、失败类似,记录日志,然后返回前端json。

1
2
3
4
5
6
7
8
9
10
11
复制代码@Slf4j
@Component
public class LogoutSuccessHandler implements org.springframework.security.web.authentication.logout.LogoutSuccessHandler {

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

// TODO 登出成功 记录登出日志
ServletUtils.render(request, response, RestResponse.success());
}
}

6. 登录超时的处理

用户登录后,当达到超时时间后(session过期),自动将用户退出登录

1
2
3
4
5
6
7
8
9
10
11
复制代码@Slf4j
@Component
public class InvalidSessionHandler implements InvalidSessionStrategy {

@Override
public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
log.info("用户登录超时,访问[{}]失败", request.getRequestURI());

ServletUtils.render(request, response, RestResponse.fail(ResponseCode.USER_LOGIN_TIMEOUT));
}
}

7. 同一账号同时登录的用户数受限的处理

比如某用户同时登陆的会话数,超过了系统的设置,大白话就是被顶号了,这时会由SessionInformationExpiredStrategy处理。
还有,在线用户被管理员提出后,也会触发。

1
2
3
4
5
6
7
8
9
10
11
复制代码@Slf4j
@Component
public class SessionInformationExpiredHandler implements SessionInformationExpiredStrategy {

@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent sessionInformationExpiredEvent) throws IOException, ServletException {

ServletUtils.render(sessionInformationExpiredEvent.getRequest(),
sessionInformationExpiredEvent.getResponse(), RestResponse.fail(ResponseCode.USER_MAX_LOGIN));
}
}

8. 自定义鉴权的实现

当用户登录后,怎么能判定用户是否有权限访问该资源呢?还记得咱们在**【2. 基于数据库的用户登录认证逻辑】**,从数据库中会把用户的权限角色查出来了,为咱们现在的鉴权提供的基础。

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
复制代码@Slf4j
@Service("ps")
public class PermissionService {

public boolean permission(String permission) {
LoginUser loginUser = UserUtil.loginUser();
for (String userPermission : loginUser.getUser().getPermissions()) {
if (permission.matches(userPermission)) {
return true;
}
}
if (log.isDebugEnabled()) {
log.debug("用户userId={}, userName={} 权限不足以访问[{}], 用户具有权限:{}, 访问", loginUser.getUser().getUserId(),
loginUser.getUsername(), permission, loginUser.getUser().getPermissions());
} else {
log.info("用户userId={}, userName={} 权限不足以访问[{}]", loginUser.getUser().getUserId(), loginUser.getUsername(), permission);
}
return false;
}
}

@RestController
public class UserController {

@Autowired
protected IUserService userService;

@GetMapping("/user/page")
@ApiOperation(value = "分页查询用户")
@PreAuthorize("@ps.permission('system:user:page')")
public TableResponse<UserVO> page() {
IPage<User> page = userService.getPage();

List<UserVO> userVOList = page.getRecords().stream().map(e -> {
UserVO userVO = new UserVO();
BeanUtils.copyPropertiesIgnoreNull(e, userVO);
return userVO;
}).collect(Collectors.toList());

return TableResponse.success(page.getTotal(), userVOList);
}
}

使用@PreAuthorize注解,即可保护应用的资源。不过,需要配置 @EnableGlobalMethodSecurity(prePostEnabled = true) 才能使@PreAuthorize生效

9. 登录用户没有权限访问的处理

用户虽然登录了,但是权限不够访问某些资源,这时候就需要AccessDeniedHandler来处理了

1
2
3
4
5
6
7
8
9
复制代码@Slf4j
@Component
public class LoginUserAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
ServletUtils.render(request, response, RestResponse.build(ResponseCode.NO_AUTHENTICATION));
}
}

10. 自定义Session解析器

官方实现了Cookie和 Session的解析,在实际的项目中,还会遇到token拼接到URL上的情况,这时候可以HttpSessionIdResolver接口

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
复制代码/**
* 同时支持 sessionId 存到 cookie,header 和 request parameter
*
* @author songyinyin
* @date 2020/3/18 下午 05:53
*/
@Slf4j
@Service("httpSessionIdResolver")
public class RestHttpSessionIdResolver implements HttpSessionIdResolver {

public static final String AUTH_TOKEN = "GitsSessionID";

private String sessionIdName = AUTH_TOKEN;

private CookieHttpSessionIdResolver cookieHttpSessionIdResolver;

public RestHttpSessionIdResolver() {
initCookieHttpSessionIdResolver();
}

public RestHttpSessionIdResolver(String sessionIdName) {
this.sessionIdName = sessionIdName;
initCookieHttpSessionIdResolver();
}

public void initCookieHttpSessionIdResolver() {
this.cookieHttpSessionIdResolver = new CookieHttpSessionIdResolver();
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setCookieName(this.sessionIdName);
this.cookieHttpSessionIdResolver.setCookieSerializer(cookieSerializer);
}


@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
// cookie
List<String> cookies = cookieHttpSessionIdResolver.resolveSessionIds(request);
if (CollUtil.isNotEmpty(cookies)) {
return cookies;
}
// header
String headerValue = request.getHeader(this.sessionIdName);
if (StrUtil.isNotBlank(headerValue)) {
return Collections.singletonList(headerValue);
}
// request parameter
String sessionId = request.getParameter(this.sessionIdName);
return (sessionId != null) ? Collections.singletonList(sessionId) : Collections.emptyList();
}

@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
log.info(AUTH_TOKEN + "={}", sessionId);
response.setHeader(this.sessionIdName, sessionId);
this.cookieHttpSessionIdResolver.setSessionId(request, response, sessionId);
}

@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
response.setHeader(this.sessionIdName, "");
this.cookieHttpSessionIdResolver.setSessionId(request, response, "");
}
}

配置Spring Security

做了这么多的准备工作后,终于到了配置的时候了,Spring Security通过建造者模式,使得配置变得简单。

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
77
78
79
80
81
82
83
84
85
86
87
88
89
复制代码@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private DefaultUserDetailsService userDetailsService;
/**
* 登出成功的处理
*/
@Autowired
private LoginFailureHandler loginFailureHandler;
/**
* 登录成功的处理
*/
@Autowired
private LoginSuccessHandler loginSuccessHandler;
/**
* 登出成功的处理
*/
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
/**
* 未登录的处理
*/
@Autowired
private AnonymousAuthenticationEntryPoint anonymousAuthenticationEntryPoint;
/**
* 超时处理
*/
@Autowired
private InvalidSessionHandler invalidSessionHandler;
/**
* 顶号处理
*/
@Autowired
private SessionInformationExpiredHandler sessionInformationExpiredHandler;
/**
* 登录用户没有权限访问资源
*/
@Autowired
private LoginUserAccessDeniedHandler accessDeniedHandler;

/**
* 配置认证方式等
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}

/**
* http相关的配置,包括登入登出、异常处理、会话管理等
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable();
http.authorizeRequests()
// 放行接口
.antMatchers(GitsResourceServerConfiguration.AUTH_WHITELIST).permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
// 异常处理(权限拒绝、登录失效等)
.and().exceptionHandling()
.authenticationEntryPoint(anonymousAuthenticationEntryPoint)//匿名用户访问无权限资源时的异常处理
.accessDeniedHandler(accessDeniedHandler)//登录用户没有权限访问资源
// 登入
.and().formLogin().permitAll()//允许所有用户
.successHandler(loginSuccessHandler)//登录成功处理逻辑
.failureHandler(loginFailureHandler)//登录失败处理逻辑
// 登出
.and().logout().permitAll()//允许所有用户
.logoutSuccessHandler(logoutSuccessHandler)//登出成功处理逻辑
.deleteCookies(RestHttpSessionIdResolver.AUTH_TOKEN)
// 会话管理
.and().sessionManagement().invalidSessionStrategy(invalidSessionHandler) // 超时处理
.maximumSessions(1)//同一账号同时登录最大用户数
.expiredSessionStrategy(sessionInformationExpiredHandler) // 顶号处理
;

}

}

@EnableWebSecurity注解用来启用Spring Security,@EnableGlobalMethodSecurity(prePostEnabled = true)用来使@PreAuthorize生效。还有一部分细节写在代码的注释里了,这样看起来更方便直观点。

配置完成后,post请求ip:port/login,就可以看到登录的结果了,如下:

image.png

后记

到此,你应该能配置出较为完善的安全框架了,本文的所有代码都已经开源,并且经过了测试。

地址:gitee.com/songyinyin/…

按照本文的思路和步骤,你已经迈过了SpringSecurity最初的一步,它让你对整个Security框架有个大概的了解,当然,肯定会有一些疑问,比如为什么从头到尾没有看到登录的接口?登录的时候,怎么就跳到了UserDetailsService#loadUserByUsername()方法中的?

不妨留言说说你刚接触SpringSecurity时的疑惑


微信搜索「 读钓的YY 」,第一时间阅读优质原创好文。

原创不易,读到最后,请为本文点个赞吧,感谢万分。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%