赞
踩
本文讲解使用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项目中,但是企业级的使用上,还是需要稍微改造下,本文实现了如下功能:
<!--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,核心思路就是:重写某个功能,然后配置。
UserDetailsService接口;AuthenticationFailureHandler/AuthenticationSuccessHandler接口;HttpSessionIdResolver接口;最后,将上述做的更改配置到security里。套路就是这个套路,下边咱们实战一下。
Don’t bb, show me code.
实现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方法中。
从数据库中查出登录用户的信息(如密码)、角色、权限等,然后返回一个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接口,用户认证成功后,擦除密码,然后返给前端。
登录成功后,一般要记录登录日志,然后把认证之后的用户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));
}
}
登录失败后,可以根据不同的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); } }
和登录成功、失败类似,记录日志,然后返回前端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());
}
}
用户登录后,当达到超时时间后(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));
}
}
比如某用户同时登陆的会话数,超过了系统的设置,大白话就是被顶号了,这时会由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));
}
}
当用户登录后,怎么能判定用户是否有权限访问该资源呢?还记得咱们在 【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生效
用户虽然登录了,但是权限不够访问某些资源,这时候就需要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));
}
}
官方实现了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通过建造者模式,使得配置变得简单。
@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.
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。