SpringBoot前后端分离项目,集成Spring Security(完整版)

19 小时前 阅读数: 4

本文讲解使用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

快速使用,引入依赖

<!--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接口,可以处理匿名用户访问无权限资源时的异常,如下:

@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会自动根据密码和用户相关状态(是否锁定、是否启停、是否过期等)判断用户登录成功或者失败。

@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返给前端

@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,来区分是为什么登录失败,这里需要有日志打印,然后根据业务需求,返回信息给前端。比如要求是无论什么错误,都返回登录失败,这里的示例是进行了登录失败的区分。

@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。

@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过期),自动将用户退出登录

@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处理。

还有,在线用户被管理员提出后,也会触发。

@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. 基于数据库的用户登录认证逻辑】,从数据库中会把用户的权限角色查出来了,为咱们现在的鉴权提供的基础。

@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来处理了

@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接口

/**
* 同时支持 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通过建造者模式,使得配置变得简单。

@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,就可以看到登录的结果了,如下:

后记

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

地址:https://gitee.com/songyinyin/gits

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

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


End.

用户头像

读钓

关注

Java开发,专心练剑 2017.11.10 加入

公众号:读钓的YY,欢迎关注

评论

发布
暂无评论
SpringBoot前后端分离项目,集成Spring Security(完整版)-InfoQ写作平台