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

3,714 阅读9分钟

本文讲解使用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,就可以看到登录的结果了,如下:

image.png

后记

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

地址:gitee.com/songyinyin/…

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

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


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

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