最近写了一个小程序, 后端用 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
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 参数, 没有非常深入的东西
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
UsernamePasswordAuthenticationToken 这个类是比较重要 Authentication 类, 基本就相当于持有鉴权 未认证前, 存储 username/password/detail 认证后, 存储服务端发放的认证信息, 比如 Token?Cookie?Session?Or Other?
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();
}
}
篇幅有限, 这里就不继续贴代码了
基于此, 甚至还可以实现各类情况的鉴权认证过程, 不局限以上
Oauth2 的认证流程实现肯定是不一样的, 暂时不在此进行讨论
类似直接写一个 Controller, 以常规化的 controller->service->dao(mapper)的方式, 比比皆是
当然也同样检索到类似我上述实现方式的文章, 只是大同小异
以上是 Security 的正确打开方式? 还有其他实现思路和方案吗?
我自己的实现方式, 始终给我一种不够优雅, 不够简洁, 甚至于不方便定位的感觉
所以我想请教佬们关于这一点的看法
1
catamaran 172 天前
一直弄不懂 spring security ,学习了,比专门的教程还好懂
|
2
Yukineko 172 天前
正文太长了不想看..我觉得 sa-token 更好用
|
3
totoro52 172 天前
非常正确,security 就是这么恶心,这是高级用法,自定义 filter 和 provider ,传统最基础的就是实现 UserDetailsService 接口,定义一个 passwordEncoder ,传入 usernamePassowrdAuthenticationToken 即可实现最基础的认证。
|
4
totoro52 172 天前
我当初研究它也是,阅读了整套源码,最后摸索出来这套方法,等到我完全实现业务的时候,发现已经违背了初心,当初就是为了图方便才上的 security 框架,结果没有给我省心还给我闹心,最后手写了一个简单的。。
|
5
Ashe007 172 天前 via iPhone
你的直觉是正确的,其实最简洁的实现不必拘泥于很多项目中着手的 UserDetails 接口的 loadByUser 方法,只需要配置好 websecurity 的一个自定义过滤器,要求必须携带正确且具有时效性的 token 即可
由于该 token 是你自己通过一个 key 和某种算法生成,因此逆向校验合法性+时效性 登录接口鉴权,鉴权通过再授权 token 即可 至于登录方式则有账号密码,手机验证码,第三方授权(我当时实现的是钉钉扫码登录)等 Filter 会拦截你配置的所有 url ,然后进行鉴权 |
6
ke1e 172 天前 via Android
就不要用 security 这个恶心的玩意,spring 的东西都是又臭又长,封装一层再一层。鉴权没必要这么麻烦,sa-token 就行了
|
9
xubeiyou 171 天前
这个东西现在确实太抽象了 复杂化了
|
10
libobo 171 天前
核心就是过滤器 filter ,你自己定义一个 filter 就够简洁,过滤请求,然后用户名密码验证作为业务逻辑,security 太重了,也不喜欢用
|
11
yuezk 171 天前 1
诚意满满的技术讨论贴👍,很想认真讨论一下这个问题。
我所在的组一直在负责公司主要产品的 authentication 部分,用的也是 Spring Security ,个人对 Spring Security 有一些粗浅的认识。我先说一下我读完之后的感觉,不一定对,欢迎批评指正。后面我会基于我对楼主需求的理解,实现一个放到 GitHub 上。 1. 个人感觉楼主前半部分对 Spring Security 的理解是没有问题的,Spring Security 就是通过一系列的 Filter 和 Provider 来实现不同的认证方式的。作为业务开发者,我们需要聚焦在如何使用 Spring Security 提供的 Filter 和 Provider 或者自定义 Filter 和 Provider 来实现我们的业务需求。 2. 楼主就是自定义了 Filter 和 Provider ,并做了一定程度的封装 (以 Global* 开头的类)。楼主自己感觉实现不够简洁,我也有同感。个人感觉这里面的原因很大程度是因为这层封装。我觉得这层封装是没有必要的,因为 Spring Security 本身就是一个很好的封装了。如果看这些以 Global* 开头的类,会发现这些类基本也是在调用 Spring Security 提供的 API 。可能没有起到简化代码的作用,反而增加了代码的复杂度。 3. 一个小问题,像 `AuthenticationManager` 和 `AntPathRequestMatcher` 这种 bean ,一般都是让框架自己注入的,没太必要自己去 new 或者用 `SpringUtil` 去获取。 |
12
Chinsung 171 天前
之前用过 shiro ,感觉 spring 在这个方向走错了,shiro 和 spring security 都过于复杂和庞大了
|
13
cppc 171 天前
抓个小虫
`这是 Spring Security 中非常关键的一个基类, 等同于 Spring 框架原生态的给了你一个实现模板, 以常见的 username/password 的形式, 实现了基本的鉴权入口` 应该叫认证哈,鉴权是另一个的概念,你这篇文章都是在讲认证处理。 |
14
bxb100 171 天前
|
15
youzizzz 171 天前
如果是我的话,我的选择是 继承 DaoAuthenticationProvider 改写 additionalAuthenticationChecks 方法,然后在调用/login 的时候魔改原始密码,拼接上指定的前缀,根据前缀进行不同场景特殊处理;
|
16
NotoChen OP @yuezk 感谢你的回复以及你对目前实现方案的肯定
你说的小问题也是对的, 但是因为无伤大雅所以没有过度关注 另外某种程度上我理想情况下的优雅和简洁,就是把这一套流程, 可动态配置化 比如不同登陆 url 的配置, 参数取值的配置, 参数 Key 的配置, 再比如认证的逻辑整合等等, 但是我没有一个明确的思路 |
19
abcbuzhiming 171 天前
不建议用 spring security ,我记得是在哪里看过一篇文章,spring security 是前后端不分离时代的思路——全流程控制,从后端一直控制到前端。所以它才实现的如此麻烦,如此的“累赘”,而你现在开发的东西基本上都已经是经历过前后端分离洗礼后的,所以你用这玩意就觉得万分不适配。
@Chinsung 你的感觉是没错的,shiro 勉强还好点,给了一些自由发挥的空间,spring security 完全是把你框死必须按他那套“大而全的全领域控制”逻辑走。 个人真不建立在比较简单的场合用 shiro 和 security 。尤其是 security ,根本就不适合目前这种后端不吐出 html ,只吐数据,需要轻量化 token 的场合 |
20
chuck1in 170 天前
spring security 的最佳实践可以看这个项目里面的内容
https://www.mjga.cc 这个就是纯按照官方手册的最佳实践来打造的,它走的是 jwt + cookie 的认证模式。你的小程序也是一种 web 程序,所以走这个模式是没有问题的,另外即使是 app 现在也有 cookie 的。 还有就是 op 你想讨论的应该是 authentication —— 这叫做身份认证。你说的鉴权是指对客户端权限的鉴定,这叫做 authorization 这是两个不同的单词,如果你要严谨的讨论这个技术问题的话,首先这个称呼的问题不要搞混了。 spring security 针对 authentication 和 authorization 是两套流程,他们之间在 filter 上有联系,但是功能上是完全区分的。 |
21
bill110100 169 天前
@youzizzz 如果用户的密码就是用你用来区分的前缀开头,那怎么办?
|