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

167 天前
 NotoChen

前言

最近写了一个小程序, 后端用 springboot 搭建的单体, 鉴权就是使用的springboot + security 因为实现方式和网上检索的教程有些差异, 所以想请教一下, 接入 security 的正确打开方式是什么

正文

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

项目结构

├── 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

思路讲解

阅读源码

UsernamePasswordAuthenticationFilter

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

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

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
	}

	public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username : "";
		username = username.trim();
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

这是 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

public interface AuthenticationProvider {

	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	boolean supports(Class<?> authentication);

}

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

寻找思路

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

请求进入 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;
public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}
public interface UserDetailsChecker {
	void check(UserDetails toCheck);

}

同样也是 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 鉴权认证逻辑

篇幅有限, 这里就不继续贴代码了

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

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

网络检索

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

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

疑惑

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

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

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

1952 次点击
所在节点    Java
21 条回复
bill110100
164 天前
@youzizzz 如果用户的密码就是用你用来区分的前缀开头,那怎么办?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1055682

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX