前言
- 之前有在坛子里看到大佬谈论过鉴权框架的问题, 其中一句话提到, 用spring不用security就很不地道, 原话不太记得, 大致是这个意思, 但是我觉得认可, 只不过存疑, 因此本文不探讨框架的差异和好坏, 不参与框架选型的讨论
- 最近写了一个小程序, 后端用springboot搭建的单体, 鉴权就是使用的springboot + security
- 因为实现方式和网上检索的教程有些差异, 所以想请教一下, 接入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的正确打开方式? 还有其他实现思路和方案吗?
我自己的实现方式, 始终给我一种不够优雅, 不够简洁, 甚至于不方便定位的感觉
所以我想请教佬们关于这一点的看法