[Java][SpringBoot][SpringSecuriy]关于代码编写的请教/探讨/求证

前言

  1. 之前有在坛子里看到大佬谈论过鉴权框架的问题, 其中一句话提到, 用spring不用security就很不地道, 原话不太记得, 大致是这个意思, 但是我觉得认可, 只不过存疑, 因此本文不探讨框架的差异和好坏, 不参与框架选型的讨论
  2. 最近写了一个小程序, 后端用springboot搭建的单体, 鉴权就是使用的springboot + security
  3. 因为实现方式和网上检索的教程有些差异, 所以想请教一下, 接入security的正确打开方式是什么

正文

我会先讲解一下我的实现思路, 实现方法, 设计思路, 思路来源等等
然后举例说明网络检索的实现方式和差异
接着倒出我的疑惑, 不吝赐教, 烦请指正

框架版本

Java = 8
SpringBoot = 2.5.x
Maven = 3.x.x

项目结构

├── security
│   ├── config
│   │   └── WebSecurityConfiguration.java
│   ├── filter
│   │   ├── GlobalBearerAuthenticationConverter.java
│   │   └── GlobalBearerTokenAuthenticationFilter.java
│   ├── handler
│   │   ├── GlobalAccessDeniedHandler.java
│   │   ├── GlobalAuthenticationEntryPoint.java
│   │   ├── GlobalAuthenticationFailureHandler.java
│   │   └── GlobalAuthenticationSuccessHandler.java
│   ├── holder
│   │   └── UserHolder.java
│   ├── impl
│   │   ├── GlobalAuthenticationFilter.java
│   │   ├── GlobalAuthenticationProvider.java
│   │   ├── GlobalAuthenticationToken.java
│   │   ├── phone
│   │   │   ├── program
│   │   │   │   └── mini
│   │   │   │       └── wechat
│   │   │   │           ├── WeChatMiniProgramAuthenticationFilter.java
│   │   │   │           ├── WeChatMiniProgramAuthenticationProvider.java
│   │   │   │           └── WeChatMiniProgramAuthenticationToken.java
│   │   │   └── sms
│   │   │       ├── PhoneSmsAuthenticationFilter.java
│   │   │       ├── PhoneSmsAuthenticationProvider.java
│   │   │       └── PhoneSmsAuthenticationToken.java
│   │   └── refresh
│   │       ├── RefreshAuthenticationFilter.java
│   │       ├── RefreshAuthenticationProvider.java
│   │       └── RefreshAuthenticationToken.java
│   ├── properties
│   │   ├── JwtProperties.java
│   │   └── WebSecurityProperties.java
│   ├── service
│   │   ├── GlobalUserDetailsChecker.java
│   │   ├── GlobalUserDetailsService.java
│   │   └── UserInitService.java
│   └── source
│       ├── GlobalWebAuthenticationDetails.java
│       └── GlobalWebAuthenticationDetailsSource.java

思路讲解

接入框架

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

springboot 生态接入组件是非常简单的事情, 脚手架/基础/配置等此处不再赘述, 论坛都是大佬, 基础的没必要多说

阅读源码

UsernamePasswordAuthenticationFilter

这是Spring Security中非常关键的一个基类, 等同于Spring框架原生态的给了你一个实现模板, 以常见的username/password的形式, 实现了基本的鉴权入口

private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login","POST");

这行代码决定了这个 Filter 只用来处理 Http 接口路径 为 /login 的请求

解析参数
		String username = obtainUsername(request);
		username = (username != null) ? username : "";
		username = username.trim();
		String password = obtainPassword(request);
		password = (password != null) ? password : "";

本质就是读取Query参数, 没有非常深入的东西

构建Authentication
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);

UsernamePasswordAuthenticationToken 这个类是比较重要Authentication类, 基本就相当于持有鉴权
未认证前, 存储username/password/detail
认证后, 存储服务端发放的认证信息, 比如Token?Cookie?Session?Or Other?

构建Detail
setDetails(request, authRequest);

这个details本质上也是Authentication中比较重要的东西, 可以用来解析和存储鉴权相关的数据, 比如请求头解析, UA/IP/Token/SessionId/Cookies等等, 具体看想怎么用

鉴权验证
return this.getAuthenticationManager().authenticate(authRequest);

getAuthenticationManager().authenticate()这是一段非常关键的代码, 因为此刻会进入Security除了Filter以外, 另外一个非常重要的概念, Provider, 也就是AuthenticationProvider

他的作用就是接收未认证前的Authentication, 进行解析,验证等操作, 然后返回认证后的Authentication
同时supports(Class<?> authentication)函数则是为了区分不同的Authentication

寻找思路

基于以上源码可知, Security最基本的几个单元已经找到了

  • Filter
  • Authentication
  • Provider

请求进入Web容器, 经由过滤器, 当Filter判断请求路径为登录请求, 则根据参数生成未认证Authentication, 然后将未认证Authentication交由Provider进行认证, 并返回认证后的Authentication

Filter可以制定请求路径, 可以处理一个或者多个请求路径
Authentication可以制定存储单元, 不同的登陆方式存储单元不同
Provider可以进行认证, 可以根据不同的Authentication来处理

基于以上结论, 那么我的基本思路是不是就可以有了

于是乎, 就有了以下设计方案

设计思路

public abstract class GlobalAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private final HttpMethod method;

    public GlobalAuthenticationFilter(String pattern, HttpMethod method) {

        super(new AntPathRequestMatcher(pattern, method.name()), SpringUtil.getBean(AuthenticationManager.class));
        this.method = method;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {

        if (!method.matches(request.getMethod())) {
            throw new AuthenticationServiceException("登陆请求协议不支持");
        }

        GlobalAuthenticationToken authentication = combinationAuthentication(request);

        return getAuthenticationManager().authenticate(authentication);
    }

    public abstract GlobalAuthenticationToken combinationAuthentication(HttpServletRequest request) throws IOException;

我先定义一个全局抽象Filter基类GlobalAuthenticationFilter, 将必要的Filter实现流程定义好

然后将请求路径和协议, 通过构造函数的形式, 限制子实现的基本构造

定义一个combinationAuthentication函数, 将参数的解析和无认证的Authentication生成交由子实现

public class GlobalAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private final Object credentials;

    public GlobalAuthenticationToken(Object principal) {

        this(principal, null);
    }

    public GlobalAuthenticationToken(Object principal, Object credentials) {

        this(principal, credentials, null);
    }

    public GlobalAuthenticationToken(Object principal, Object credentials, Object details) {

        super(AuthorityUtils.NO_AUTHORITIES);
        this.principal = principal;
        this.credentials = credentials;
        setDetails(details);
    }

    public GlobalAuthenticationToken(
            Object principal, Object credentials, Object details, Collection<? extends GrantedAuthority> authorities
    ) {

        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        setDetails(details);
        setAuthenticated(true);
    }

    public static GlobalAuthenticationToken unauthenticated(Object principal) {

        return new GlobalAuthenticationToken(principal);
    }

    public static GlobalAuthenticationToken unauthenticated(Object principal, Object credentials) {

        return new GlobalAuthenticationToken(principal, credentials);
    }

    public static GlobalAuthenticationToken unauthenticated(Object principal, Object credentials, Object details) {

        return new GlobalAuthenticationToken(principal, credentials, details);
    }

    public static GlobalAuthenticationToken authenticated(Object principal) {

        return new GlobalAuthenticationToken(principal, null, null, AuthorityUtils.NO_AUTHORITIES);
    }

    public static GlobalAuthenticationToken authenticated(Object principal, Object credentials) {

        return new GlobalAuthenticationToken(principal, credentials, null, AuthorityUtils.NO_AUTHORITIES);
    }

    public static GlobalAuthenticationToken authenticated(Object principal, Object credentials, Object details) {

        return new GlobalAuthenticationToken(principal, credentials, details, AuthorityUtils.NO_AUTHORITIES);
    }

    @Override
    public Object getCredentials() {

        return credentials;
    }

    @Override
    public Object getPrincipal() {

        return principal;
    }

}

我先定义一个全局Authentication基类GlobalAuthenticationToken, 包含基本的principal/credentials以及源于AbstractAuthenticationToken的detail和authorities

因为某些鉴权场景的特殊性, 我将构造函数尽可能全面的限制, 以防子实现出现缺漏, 并提供了足够的静态函数来支撑, 简化构造流程

@SuppressWarnings("unchecked")
public abstract class GlobalAuthenticationProvider<AuthenticationToken extends Authentication> implements AuthenticationProvider {

    private final Class<AuthenticationToken> clazz;
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private UserDetailsChecker userDetailsChecker;

    {
        Type superClass = getClass().getGenericSuperclass();
        if (superClass instanceof ParameterizedType) {
            this.clazz = (Class<AuthenticationToken>) ((ParameterizedType) superClass).getActualTypeArguments()[0];
        } else {
            throw new IllegalArgumentException("泛型类型未找到");
        }
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = validate4Username((AuthenticationToken) authentication);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        userDetailsChecker.check(userDetails);
        GlobalAuthenticationToken token = GlobalAuthenticationToken.authenticated(userDetails, null, authentication.getDetails());
        token.setDetails(authentication.getDetails());
        return token;
    }

    public abstract String validate4Username(AuthenticationToken authentication);

    @Override
    public boolean supports(Class<?> authentication) {
        return clazz.isAssignableFrom(authentication);
    }

}

我先定义一个全局抽象Provider基类GlobalAuthenticationProvider, 并严格按照Provider的核心思路进行固有实现

通过泛型参数将supports(Class<?> authentication)默认处理

定义抽象函数abstract String validate4Username(AuthenticationToken authentication) 交由子类进行认证逻辑

其中有涉及到两个重点实现

    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private UserDetailsChecker userDetailsChecker;


同样也是Security中较为核心的接口定义

其中UserDetailsService提供了开放实现接口loadUserByUsername
UserDetailsChecker提供了check

前者用来获取相关用户信息
后者用来校验相关用户信息

比如我的默认实现

@Slf4j
@Service
@RequiredArgsConstructor
public class GlobalUserDetailsService implements UserDetailsService {

    private final UserService userService;

    @Override
    public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {

        try {
            return Optional.ofNullable(userService.getByPhoneIncludeDelete(phone))
                    .orElseGet(() -> userService.newUser(phone));
        } catch (Exception e) {
            log.error("加载用户失败={}", e.getMessage(), e);
            throw new UsernameNotFoundException("手机号异常", e);
        }
    }

}
@Component
public class GlobalUserDetailsChecker implements UserDetailsChecker {

    @Override
    public void check(UserDetails user) {

        Assert.isTrue(user.isAccountNonLocked(), () -> new LockedException("账户已锁定"));

        Assert.isTrue(user.isEnabled(), () -> new DisabledException("账户已禁用"));

        Assert.isTrue(user.isAccountNonExpired(), () -> new AccountExpiredException("账户已过期"));

        Assert.isTrue(user.isCredentialsNonExpired(), () -> new CredentialsExpiredException("账户认证已过期"));

    }

}

以上只是一个很简略的认证实现

具体实现

如我在[项目结构] 中展示的

我分别基于以上抽象,

实现了, 手机号/短信验证码 鉴权认证逻辑

@Component
public class PhoneSmsAuthenticationFilter extends GlobalAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone";

    public static final String SPRING_SECURITY_FORM_SMS_CODE_KEY = "smsCode";

    public static final String SPRING_SECURITY_FROM_URI_PATTEN = "/**/user/login/phone";

    public static final HttpMethod SPRING_SECURITY_FROM_METHOD = HttpMethod.GET;

    public PhoneSmsAuthenticationFilter() {
        super(SPRING_SECURITY_FROM_URI_PATTEN, SPRING_SECURITY_FROM_METHOD);
    }

    @Override
    public GlobalAuthenticationToken combinationAuthentication(HttpServletRequest request) {
        String phone = StrUtil.nullToEmpty(
                StrUtil.cleanBlank(request.getParameter(SPRING_SECURITY_FORM_PHONE_KEY)));
        String smsCode = StrUtil.nullToEmpty(
                StrUtil.cleanBlank(request.getParameter(SPRING_SECURITY_FORM_SMS_CODE_KEY)));
        GlobalWebAuthenticationDetails details = (GlobalWebAuthenticationDetails) authenticationDetailsSource.buildDetails(
                request);
        details
                .setClientType(ClientType.WeChatMiniProgram)
                .setLoginType(LoginType.PhoneSms);
        return new PhoneSmsAuthenticationToken(phone, smsCode, details);
    }
}
@Getter
public class PhoneSmsAuthenticationToken extends GlobalAuthenticationToken {

    private final String phone;

    private final String smsCode;

    public PhoneSmsAuthenticationToken(String phone, String smsCode, Object details) {
        super(phone, smsCode, details);
        this.phone = phone;
        this.smsCode = smsCode;
    }
}
@Component
@RequiredArgsConstructor
public class PhoneSmsAuthenticationProvider extends GlobalAuthenticationProvider<PhoneSmsAuthenticationToken> {

    private final SmsService smsService;

    @Override
    public String validate4Username(PhoneSmsAuthenticationToken authentication) {
        String phone = authentication.getPhone();
        String smsCode = authentication.getSmsCode();
        String ip = ((GlobalWebAuthenticationDetails) authentication.getDetails()).getIp();
        smsService.verifySmsCode(phone, ip, smsCode);
        return phone;
    }


}

实现了, 微信小程序手机号快速验证 鉴权认证逻辑

@Component
public class WeChatMiniProgramAuthenticationFilter extends GlobalAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_APP_ID_KEY = "appId";

    public static final String SPRING_SECURITY_FORM_PHONE_CODE_KEY = "phoneCode";

    public static final String SPRING_SECURITY_FROM_URI_PATTEN = "/user/login/wechat/miniapp";

    public static final HttpMethod SPRING_SECURITY_FROM_METHOD = HttpMethod.POST;

    public WeChatMiniProgramAuthenticationFilter() {

        super(SPRING_SECURITY_FROM_URI_PATTEN, SPRING_SECURITY_FROM_METHOD);
    }

    @Override
    public GlobalAuthenticationToken combinationAuthentication(HttpServletRequest request) throws IOException {
        JSONObject paramJson = JSONUtil.parseObj(IoUtil.read(request.getInputStream(), StandardCharsets.UTF_8));
        String appId = paramJson.getStr(SPRING_SECURITY_FORM_APP_ID_KEY);
        String phoneCode = paramJson.getStr(SPRING_SECURITY_FORM_PHONE_CODE_KEY);
        GlobalWebAuthenticationDetails details = (GlobalWebAuthenticationDetails) authenticationDetailsSource.buildDetails(
                request);
        details
                .setClientType(ClientType.WeChatMiniProgram)
                .setLoginType(LoginType.WeChatMiniProgram);
        return new WeChatMiniProgramAuthenticationToken(appId, phoneCode, details);
    }

}
@Getter
public class WeChatMiniProgramAuthenticationToken extends GlobalAuthenticationToken {

    private final String appId;

    private final String phoneCode;

    public WeChatMiniProgramAuthenticationToken(String appId, String phoneCode, Object details) {

        super(appId, phoneCode, details);
        this.appId = appId;
        this.phoneCode = phoneCode;
    }
}
@Component
@RequiredArgsConstructor
public class WeChatMiniProgramAuthenticationProvider extends
        GlobalAuthenticationProvider<WeChatMiniProgramAuthenticationToken> {

    private final WxMiniAppService wxMiniAppService;

    @Override
    public String validate4Username(WeChatMiniProgramAuthenticationToken authentication) {

        if (ApplicationTools.isNotProd()) {
            throw new BadCredentialsException("当前环境不支持该登录方式!");
        }
        String appId = authentication.getAppId();
        String phoneCode = authentication.getPhoneCode();
        if (!wxMiniAppService.switchover(appId)) {
            throw new BadCredentialsException(StrUtil.format("未找到对应微信小城AppId=[{}]配置,请核实后重试", appId));
        }
        WxMaPhoneNumberInfo phoneNoInfo;
        try {
            phoneNoInfo = wxMiniAppService
                    .getUserService()
                    .getPhoneNoInfo(phoneCode);
        } catch (WxErrorException e) {
            throw new BadCredentialsException(e
                    .getError()
                    .getErrorMsg());
        }

        return phoneNoInfo.getPurePhoneNumber();
    }

}

实现了, 通过refreshToken刷新Token 鉴权认证逻辑

@Component
public class RefreshAuthenticationFilter extends GlobalAuthenticationFilter {

    public static final String SPRING_SECURITY_FORM_TOKEN_KEY = "token";

    public static final String SPRING_SECURITY_FORM_REFRESH_TOKEN_KEY = "refreshToken";

    public static final String SPRING_SECURITY_FROM_URI_PATTEN = "/refresh/token";

    public static final HttpMethod SPRING_SECURITY_FROM_METHOD = HttpMethod.PUT;

    public RefreshAuthenticationFilter() {

        super(SPRING_SECURITY_FROM_URI_PATTEN, SPRING_SECURITY_FROM_METHOD);
    }

    @Override
    public GlobalAuthenticationToken combinationAuthentication(HttpServletRequest request) throws IOException {

        JSONObject paramJson = JSONUtil.parseObj(IoUtil.read(request.getInputStream(), StandardCharsets.UTF_8));
        String token = paramJson.getStr(SPRING_SECURITY_FORM_TOKEN_KEY);
        String refreshToken = paramJson.getStr(SPRING_SECURITY_FORM_REFRESH_TOKEN_KEY);
        GlobalWebAuthenticationDetails details = (GlobalWebAuthenticationDetails) authenticationDetailsSource.buildDetails(
                request);
        details
                .setLoginType(LoginType.RefreshToken);
        return new GlobalAuthenticationToken(token, refreshToken, details);
    }

}
@Getter
public class RefreshAuthenticationToken extends GlobalAuthenticationToken {

    private final String token;

    private final String refreshToken;


    public RefreshAuthenticationToken(String token, String refreshToken, Object details) {
        super(token, refreshToken, details);
        this.token = token;
        this.refreshToken = refreshToken;
    }

}
@Component
@RequiredArgsConstructor
public class RefreshAuthenticationProvider extends GlobalAuthenticationProvider<RefreshAuthenticationToken> {

    private final JwtProperties jwtProperties;

    @Override
    public String validate4Username(RefreshAuthenticationToken authentication) {

        String token = authentication.getToken();
        String refreshToken = authentication.getRefreshToken();

        try {
            JWTUtil.verify(token, jwtProperties.getSecretKeyByte());
            JWTUtil.verify(refreshToken, jwtProperties.getSecretKeyByte());
        } catch (JWTException e) {
            throw new BadCredentialsException("凭证验证失败");
        }

        JWT jwtToken = JWTUtil.parseToken(token);
        jwtToken.setKey(jwtProperties.getSecretKeyByte());

        JWT jwtRefreshToken = JWTUtil.parseToken(refreshToken);
        jwtRefreshToken.setKey(jwtProperties.getSecretKeyByte());

        UserWrap tokenWrap = jwtToken
                .getPayloads()
                .toBean(UserWrap.class);

        UserWrap refreshTokenWrap = jwtRefreshToken
                .getPayloads()
                .toBean(UserWrap.class);

        Assert.isTrue(StrUtil.equalsIgnoreCase(tokenWrap.getSub(), "token"),
                () -> new BadCredentialsException("凭证来源不明"));

        Assert.isTrue(StrUtil.equalsIgnoreCase(refreshTokenWrap.getSub(), "refreshToken"),
                () -> new BadCredentialsException("凭证来源不明"));

        Assert.isTrue(
                jwtRefreshToken.validate(jwtProperties.getValidateLeeway()),
                () -> new AccountExpiredException("刷新凭证已过期"));

        String tokenCache = Optional
                .ofNullable(CacheTools.get("authentication:refreshToken:" + refreshTokenWrap.getAud()))
                .orElseThrow(() -> new AccountExpiredException("刷新凭证已失效"));

        Object jwtId = JWTUtil
                .parseToken(tokenCache)
                .getPayload(JWTPayload.JWT_ID);


        Assert.isTrue(
                ObjectUtil.equal(jwtRefreshToken.getPayload(JWTPayload.JWT_ID), jwtId),
                () -> new AccountExpiredException("凭证已过时"));

        return refreshTokenWrap.getMobile();
    }

}

基于此, 甚至还可以实现各类情况的鉴权认证过程, 不局限以上

Oauth2的认证流程实现肯定是不一样的, 暂时不在此进行讨论

网络检索

类似这种直接写一个Controller, 以常规化的controller->service->dao(mapper)的方式, 比比皆是

当然也同样检索到类似我上述实现方式的文章, 只是大同小异

疑惑

以上是Security的正确打开方式? 还有其他实现思路和方案吗?

我自己的实现方式, 始终给我一种不够优雅, 不够简洁, 甚至于不方便定位的感觉

所以我想请教佬们关于这一点的看法

52 个赞

很厉害,但是有点复杂

1 个赞

在论坛可以被称作鸿篇巨著了

2 个赞

大佬牛逼

3 个赞

SpringSecurity我记得就是比Shiro复杂的
直接看官方文档呗,范例什么都有,网上多半也是复制粘贴或者汉化一下
https://docs.spring.io/spring-security/reference/index.html

1 个赞

哇,已经看不懂了,厉害

1 个赞

但是其实我觉得controller的方式会更复杂一些, 不够统一

3 个赞

复杂其实没关系, 重点在于能实现就好, 官方文档主要是说明了各类核心类的作用, 也只给出了简单范例, 类似我说的username/password, 其他登陆方式的其实没有更详细的

1 个赞

巨作,收藏了,慢慢学习

1 个赞

xue xi yi xia

1 个赞

分析问题,抽象问题思路好清晰啊,我要是这么厉害就好了

1 个赞

收藏一下慢慢看

1 个赞

我有个疑问,每个业务异常都新建个exception类会不会导致工作量陡然提升

1 个赞

真的厉害

6 个赞

有时候感觉用Security很笨重,不如直接自己写filter来的快。
可能我还没遇到更复杂的业务

2 个赞

你截图的异常都是框架异常,我个人主张单自定义异常就够了

懂了,我以为楼主你每个业务异常自己都新建了个类呢。:joy:,是我学艺不精,没提前了解这个框架。

目前我们整个部门的登录验证都是用的filter​:sweat_smile:

1 个赞

SpringSecurity确实要复杂一些,但是功能比较完善,就好比很多安全框架都没法编写单元测试,但是Spring自家的SpringSecurity对单元测试就支持得很好,用起来很方便。至于优雅,感觉SpringSecurity的代码确实不够优雅,还有很多提升空间,但是也是比其他大多数框架优雅,很多框架只是牺牲了功能来提高易用性而已。

1 个赞

我个人理解你这个应该就是不同登陆方式,但是最后都会通过validate4Username进行转换,然后进行loadUsername的验证, 看着你配置配置不同的url AntMatcher感觉好像没必要,其实只需要filter把参数转换好久行了.

推荐参考
org.springframework.security.web.authentication.www.BasicAuthenticationFilter的实现

它里面实际是通过BasicAuthenticationConverter 转换成UsernamePasswordAuthenticationToken, 然后完成验证

你的validate4Username的方法应该实现不同的Converter就可以了

这样只需要实现几个filter就足够了,并且放在UsernamePasswordAuthenticationFilter之前, 只需要实现自定义的UserDetailsService,至于UserDetailsChecker, 默认的AccountChecker 感觉已经足够用了不太需要

根据上面的推论之后扩展新的方式实际上就是实现一个filter 一个 对应的converter 就可以了.

2 个赞