纸上得来终觉浅,绝知此事要躬行。
阅读本文:
如需简单使用:SpringBoot集成SpringSecurity做安全框架、附源码
你能收获:
SpringSecurity
鉴权流程。SpringSecurity
鉴权流程图。SpringSecurity
框架会有更深一步的理解,能够在使用时做到更高程度的定制化。SpringSecurity
更深一步的思考xdm,我还是没有学会写小故事,我只可以在这里请你们喝可乐,请 xdm 赏个一键三连。
xdm,不知道你们在使用SpringSecurity安全框架的时候,有没有想过 debug 一步一步看它是如何实现判断是否可以访问的?
如下:
@PreAuthorize("hasRole('ROLE_ADMIN')")
@RequestMapping("/role/admin1")
String admin() {
return "role: ROLE_ADMIN";
}
为什么我们写上这个注解可以了呢?如何进行判断的呢?
前面写过一次 SpringSecurity 登录流程分析,写那篇文章是为了写 SpringSecurity 实现多种登录方式做铺垫。
那么这次写这个文章的原因呢?
在掘金看到了掘友的 和耳朵 写的 SpringSecurity 动态鉴权流程分析,才发觉用注解其实也不是非常好的事情,直接固定在项目,无法做到动态的更改,是个要不得的事情(捂脸),之前只考虑到这么写蛮好的,看完文章才恍然大悟。这两天也准备实现一下Security的动态鉴权的小demo。
xdm,一定要记得,
纸上得来终觉浅,绝知此事要躬行
,尤其是一路 debug 的文章,亲身踩坑。
对于一门技术,会使用是说明我们对它已经有了一个简单了解,把脉络、细节都掌握清楚,我们才能更好的使用。
接下来就让来带大家一起看看吧。
下图是在百度找的一张关于 Security 原理图
我接下来画的流程图是基于用户已经登录的状态下的画的。
整个认证的过程其实一直在围绕图中过滤链的绿色部分,而我们今天要说的鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor
。
这也就是我流程图的开始,如下图:
上图如有不妥之处,请大家批正,在此郑重感谢。
关于上图的粗略解释,后文再一一道来:
1、登录后,用户访问一个需要权限的接口,经过一连串过滤器,到达 FilterSecurityInterceptor
, FilterSecurityInterceptor 的invoke()方法执行具体拦截行为,具体是 beforeInvocation、finallyInvocation、afterInvocation 这三个方法,这三个方法是定义在父类 AbstractSecurityInterceptor
中。
2、调用 AbstractSecurityInterceptor 的 beforeInvocation 方法。AbstractSecurityInterceptor
将确保安全拦截器的正确启动配置。它还将实现对安全对象调用的正确处理,即:
SecurityMetadataSource..getAttributes(object)
;返回个 Collection< ConfigAttribute > attributesattemptAuthorization(object, attributes, authenticated);
调用 AccessDecisionManager 接口 decide 方法,执行鉴权,鉴权不成功,会直接抛异常。3、经过千辛万苦后,到达MethodSecurityInterceptor,由它再次重新调用起 AbstractSecurityInterceptor.beforeInvocation(mi) 方法,来进行权限的验证
PreInvocationAuthorizationAdviceVoter
进入正题前先放张图片缓一缓:
当乌云和白云相遇时,而我与你却已经分离。
前半部分作用是在检测用户的状态,并非就是执行鉴权,不过两次都十分相近。关于方法上注解的检测是在后半部分。
第一步:FilterSecurityInterceptor void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
//过滤器链实际调用的方法。 简单地委托给invoke(FilterInvocation)方法。
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
invoke(new FilterInvocation(request, response, chain));
}
接着看 void invoke(FilterInvocation filterInvocation)
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
//过滤器已应用于此请求,用户希望我们观察每个请求处理一次,因此不要重新进行安全检查
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// 第一次调用这个请求,所以执行安全检查
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//调用 beforeInvocation(filterInvocation) 方法 跟着这个方法往下看
InterceptorStatusToken token = super.beforeInvocation(filterInvocation) ;
try {
//每个过滤器都有这么一步
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
//在安全对象调用完成后清理AbstractSecurityInterceptor的工作。
//无论安全对象调用是否成功返回,都应该在安全对象调用之后和 afterInvocation 之前调用此方法(即它应该在 finally 块中完成)。
super.finallyInvocation(token);
}
//当调用afterInvocation(InterceptorStatusToken,Object)时,AbstractSecurityInterceptor不会采取进一步的操作。
super.afterInvocation(token, null);
}
授权检查 beforeInvocation() 方法
第二步:super.beforeInvocation(filterInvocation); 一些打印信息被精简了,太长不适合阅读
protected InterceptorStatusToken beforeInvocation(Object object) {
//检查操作
Assert.notNull(object, "Object was null");
if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
//....
}
//这里获取的信息看下图示1 :
//object 就是调用处传过来的参数 FilterInvocation filterInvocation,它本身其实就是 HttpServletRequest 和 HttpServletResponse 的增强
//object :filter invocation [GET /role/admin1] "
//然后我们获取到的就是受保护调用的列表
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
if (CollectionUtils.isEmpty(attributes)) {
//...
return null; // no further work post-invocation
}
//在 SecurityContext 中未找到身份验证对象,会发事件抛异常
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"), object, attributes);
}
//在这里拿到了 Authentication 对象登录的信息 ,后文会简单说是如何拿到的
Authentication authenticated = authenticateIfRequired();
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorizing %s with attributes %s", object, attributes));
}
// Attempt authorization : 尝试授权 这步本文重点,用我的话来说,这就是鉴权的入口 重点关注,下文继续
attemptAuthorization(object, attributes, authenticated);
//...
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs != null) {
//...
}
// 无后续动作
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
}
关于 Collection< ConfigAttribute > attributes = this.obtainSecurityMetadataSource().getAttributes(object);
这段代码。
第一次访问这里的时候,FilterSecurityInterceptor
是从 SecurityMetadataSource
的子类 DefaultFilterInvocationSecurityMetadataSource
获取到当前的是这样的数据。它和我们第二次来执行这里有很大的区别。这里的表达式是 authenticated
,翻译过来就是认证过的。
在后文会进行比较的。
我们接着往下看:Authentication authenticateIfRequired() 获取身份信息
//如果Authentication.isAuthenticated()返回 false 或属性alwaysReauthenticate已设置为 true,
//则检查当前的身份验证令牌并将其传递给 AuthenticationManager进行身份验证
private Authentication authenticateIfRequired() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.isAuthenticated() && !this.alwaysReauthenticate) {
return authentication;
}
authentication = this.authenticationManager.authenticate(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}
第三步:尝试授权: attemptAuthorization()
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) {
try {
//接着套娃 我们去看 AccessDecisionManager 下的 decide() 方法
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException ex) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,
attributes, this.accessDecisionManager));
}
else if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
}
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
throw ex;
}
}
this.accessDecisionManager 其实是个接口。我们一起看看它的源码
public interface AccessDecisionManager {
/**
为传递的参数解析访问控制决策。
参数:
身份验证 - 调用方法的调用者(非空)
object – 被调用的安全对象
configAttributes – 与被调用的安全对象关联的配置属性
*/
void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException;
// 下面这两个方法主要起辅助作用的。大都执行检查操作
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
}
我们先看看这个接口结构,之后再看它的实现类内部鉴权机制是如何执行的,需要获取那些信息,又是如何判断它是否可以通过的。
我们可以看到这个 AccessDecisionManager
接口,接口下有一个抽象类,然后再有了三个实现类。
他们分别代表不同的机制。
一起看看默认用的 AffirmativeBased:
public class AffirmativeBased extends AbstractAccessDecisionManager {
public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
super(decisionVoters);
}
/**
这个具体的实现只是轮询所有配置的AccessDecisionVoter并在任何AccessDecisionVoter投赞成票时授予访问权限。 仅当存在拒绝投票且没有赞成票时才拒绝访问。
如果每个AccessDecisionVoter放弃投票,则决策将基于isAllowIfAllAbstainDecisions()属性(默认为 false)。
*/
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(
this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}
到这里又会牵扯到 AccessDecisionVoter
出来,也就是能够投票的选民们。
我们先一起来看它的源码,再看看它的实现类:
//表示一个类负责对授权决定进行投票。
//投票的协调(即轮询AccessDecisionVoter ,统计他们的响应,并做出最终授权决定)由AccessDecisionManager执行。
public interface AccessDecisionVoter<S> {
int ACCESS_GRANTED = 1;
int ACCESS_ABSTAIN = 0;
int ACCESS_DENIED = -1;
//这两个用来执行check操作,判断参数是否合法等等
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
/**
指示是否授予访问权限。
决定必须是肯定的 ( ACCESS_GRANTED )、否定的 ( ACCESS_DENIED ) 或者 AccessDecisionVoter可以弃权 ( ACCESS_ABSTAIN ) 投票。
在任何情况下,实现类都不应返回任何其他值。 如果需要对结果进行加权,则应改为在自定义AccessDecisionManager处理。
除非AccessDecisionVoter由于传递的方法调用或配置属性参数而专门用于对访问控制决策进行投票,否则它必须返回ACCESS_ABSTAIN 。
这可以防止协调AccessDecisionManager计算来自那些AccessDecisionVoter的选票,而这些AccessDecisionVoter对访问控制决策没有合法利益。
虽然安全对象(例如MethodInvocation )作为参数传递以最大限度地提高访问控制决策的灵活性,但实现类不应修改它或导致所表示的调用发生(例如,通过调用MethodInvocation.proceed() ) .
*/
int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}
我们看看它的结构:
RoleVoter
主要用来判断当前请求是否具备该接口所需要的角色RoleHierarchyVoter
是 RoleVoter 的一个子类,在 RoleVoter 角色判断的基础上,引入了角色分层管理,也就是角色继承WebExpressionVoter
这是一个基于表达式权限控制的投票器Jsr250Voter
处理 Jsr-250 权限注解的投票器,如 @PermitAll
,@DenyAll
等。AuthenticatedVoter
用于判断 ConfigAttribute 上是否拥有 IS_AUTHENTICATED_FULLY、IS_AUTHENTICATED_REMEMBERED、IS_AUTHENTICATED_ANONYMOUSLY 三种角色。AbstractAclVoter
提供编写域对象 ACL 选项的帮助方法,没有绑定到任何特定的 ACL 系统。PreInvocationAuthorizationAdviceVoter
使用 @PreFilter 和 @PreAuthorize 注解处理的权限,通过 PreInvocationAuthorizationAdvice
来授权。AffirmativeBased
默认传入的构造器只有一个 WebExpressionVoter
,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果。
所以我们在执行第一次循环时,也是在这里处理的。
public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();
@Override
public int vote(Authentication authentication, FilterInvocation filterInvocation,
Collection<ConfigAttribute> attributes) {
//...执行的一些检查
//
WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
if (webExpressionConfigAttribute == null) {
return ACCESS_ABSTAIN;
}
//允许对EvaluationContext进行后处理。 实现可能会返回一个新的EvaluationContext实例或修改传入的EvaluationContext 。
EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
//调用内部模板方法来创建StandardEvaluationContext和SecurityExpressionRoot对象。
this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);
//针对指定的根对象评估默认上下文中的表达式。 如果评估结果与预期结果类型不匹配(并且无法转换为),则将返回异常。
boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
// 投赞同票,返回
if (granted) {
return ACCESS_GRANTED;
}
return ACCESS_DENIED;
}
//循环判断
private WebExpressionConfigAttribute findConfigAttribute(Collection<ConfigAttribute> attributes) {
for (ConfigAttribute attribute : attributes) {
if (attribute instanceof WebExpressionConfigAttribute) {
return (WebExpressionConfigAttribute) attribute;
}
}
return null;
}
//...
}
在这里的数据也是如此,和我们上文就互相对应上了。
4.1、先返回至AffirmativeBased.decide()方法处,投票通过,继续 retrun
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
4.2、返回至 AbstractSecurityInterceptor 方法调用处,这里是无返回值,直接回到 beforeInvocation
方法中。
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) {
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
}
4.3、再返回至beforeInvocation
方法中,
protected InterceptorStatusToken beforeInvocation(Object object) {
// 返回到这里,我们再顺着往下看,看如何执行
attemptAuthorization(object, attributes, authenticated);
// Attempt to run as a different user :尝试以其他用户身份运行
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs != null) {
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// 需要恢复到 token.Authenticated 调用后 true的意思是:如果能以其他用户运行 就执行刷新
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
}
4.4、回到了我们梦开始的地方了:FilterSecurityInterceptor.invoke() 方法
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
// 过滤器已应用于此请求,用户希望我们观察每个请求处理一次,因此不要重新进行安全检查
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// 第一次调用这个请求,所以执行安全检查
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
//返回至此处
//InterceptorStatusToken类上的doc注释说:
//AbstractSecurityInterceptor子类接收的返回对象。
//这个类反映了安全拦截的状态,以便最终调用AbstractSecurityInterceptor.afterInvocation(InterceptorStatusToken, Object)可以正确整理。
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
try {
//每个过滤器的必备代码
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
对方法注解的鉴权,是真的一步一步看它如何执行的,一直扒,真的是历经千辛万苦。
默认大家都能看的懂这个图了,我们直接转到 MethodSecurityInterceptor 里来看看它做了什么吧
//提供对基于 AOP 联盟的方法调用的安全拦截。
//此安全拦截器所需的SecurityMetadataSource是MethodSecurityMetadataSource类型。 这与基于 AspectJ 的安全拦截器 ( AspectJSecurityInterceptor ) 共享,因为两者都与 Java Method 。
public class MethodSecurityInterceptor extends AbstractSecurityInterceptor implements MethodInterceptor {
private MethodSecurityMetadataSource securityMetadataSource;
//此方法应用于对MethodInvocation强制实施安全性。
@Override
public Object invoke(MethodInvocation mi) throws Throwable {
//beforeInvocation 这个有没有似曾相识 ,莫错哈 就是我们之前在 FilterSecurityInterceptor 看到的那个
//需要注意到的是 之前我们传的参是一个 FilterInvocation ,这里则是一个 MethodInvocation 。
InterceptorStatusToken token = super.beforeInvocation(mi);
Object result;
try {
result = mi.proceed();
}
finally {
super.finallyInvocation(token);
}
return super.afterInvocation(token, result);
}
//...
}
MethodInvocation
:doc注释是"方法调用的描述,在方法调用时提供给拦截器。方法调用是一个连接点,可以被方法拦截器拦截".
另外在这里debug获取到的值也是不一样的,这点上文我刚刚也说过了。
获取资源访问策略:FilterSecurityInterceptor
会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection< ConfigAttribute >。 SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则, 读取访问策略如:
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/r1").hasAuthority("r1")
.antMatchers("/r/r2").hasAuthority("r2")
....
}
中间的过程同上半部分差不多,就不多说了。我们直接看 AffirmativeBased 情况如何。
attemptAuthorization(object, attributes, authenticated);
this.accessDecisionManager.decide(authenticated, object, attributes);
接着往下,到此处就同之前稍有不同了,我们之前用到的是 WebExpressionVoter,在这里我们使用的是: PreInvocationAuthorizationAdviceVoter
我们接着进入:PreInvocationAuthorizationAdviceVoter,它的类上的doc注释如下:
Voter 使用从 @PreFilter 和 @PreAuthorize 注释生成的 PreInvocationAuthorizationAdvice 实现来执行操作。
在实践中,如果使用这些注解,它们通常会包含所有必要的访问控制逻辑,因此基于投票者的系统并不是真正必要的,包含相同逻辑的单个AccessDecisionManager就足够了。 然而,这个类很容易与 Spring Security 使用的传统的基于投票者的AccessDecisionManager实现相适应。
我们可以很容易的看出,这个就是处理方法上注解的那个类。接着看下它的源码。
public class PreInvocationAuthorizationAdviceVoter implements AccessDecisionVoter<MethodInvocation> {
private final PreInvocationAuthorizationAdvice preAdvice;
public PreInvocationAuthorizationAdviceVoter(PreInvocationAuthorizationAdvice pre) {
this.preAdvice = pre;
}
//...一些检查方法
@Override
public int vote(Authentication authentication, MethodInvocation method, Collection<ConfigAttribute> attributes) {
// 查找 prefilter 和 preauth(或组合)属性,如果两者都为 null,则弃权使用它们调用建议
PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes);
if (preAttr == null) {
// 没有基于表达式的元数据,所以弃权
return ACCESS_ABSTAIN;
}
//在这里又委托给 PreInvocationAuthorizationAdvice接口的before方法来做判断
return this.preAdvice.before(authentication, method, preAttr) ? ACCESS_GRANTED : ACCESS_DENIED;
}
private PreInvocationAttribute findPreInvocationAttribute(Collection<ConfigAttribute> config) {
for (ConfigAttribute attribute : config) {
if (attribute instanceof PreInvocationAttribute) {
return (PreInvocationAttribute) attribute;
}
}
return null;
}
}
简单看一下PreInvocationAuthorizationAdvice接口的before方法的默认实现:
before方法的说明是:应该执行的“before”建议以执行任何必要的过滤并决定方法调用是否被授权。
我们先说说它的参数:(Authentication authentication,MethodInvocation mi,PreInvocationAttribute attr)
,第一个就是当前登录的用户,二就是要执行的方法,三就是方法上的注解信息。
我们可以很简单的看出这段代码的含义,就是在比较已经登录的用户,是否拥有这个方法上所需要的权限。
另外简单说明一下:
createEvaluationContext 的dco注释:提供评估上下文,在其中评估调用类型的安全表达式(即 SpEL 表达式)。我个人对这块没有特别深入过,没法说清楚,大家可以查一查。
另外我们看一下debug的详细信息,大家应该就差不多能懂啦。
接下来就是一步一步返回啦
最后就是:
这里的 result 就是方法执行的返回结果。紧接着就是一步一步返回过滤器链啦。
对于这里 proceed
方法就不再深入了。这个点拉出来说,怕是直接可以写上一篇完整的文章啦。
内部很多动态代理啊、反射啊这些相关的,一层套一层的,不是咱研究重点。溜啦溜啦。
这张图是在百度上搜到的,大致流程其实就是如此。
其实内部还有很多很多值得推敲的东西,不是在这一篇简单的文章中能够写出来的。
还记得我第一次说要看源码是在准备研究 Mybatis 的时候,那时候上头看了大概几天吧,看着看着就看不下去了,找不到一个合适的方法,什么都想看,没有一个非常具体的目标,导致连续受挫,结果就是不了了之了。
第二次真正意义看源码就是看 Security 。原因是当时在写项目的时,我的前端小伙伴说,现在大部分网站都有多种登录方式,你能实现不?
男人肯定是不能说不行,然后我就一口答应下来了。结果就是疯狂百度、google,到处看博客。互联网这么庞大,当然也有找到非常多的例子,也有源码解析。但是找到的文章,要么只贴出了核心代码,要么就是不合适(庞大,难以抽取),总之一句话没法运行。就很烦操。
不过文章中都提到了要理解 Security 的登录过程,然后进行仿写,俗称抄作业。最后,真就是一步一步 debug 去看 Security 的登录过程,写出了 第一篇 Security登录认证流程分析,紧接着又去用 SpringSecurity实现多种登录方式,如邮件验证码、电话号码登录。这次即是机缘巧合,也是心有所念,耗费不少时间写出了这篇文章。感觉还是非常不错的。
希望大家能够喜欢,如果 xdm 对此也感兴趣,希望大家在有时间的情况,debug 几次,记忆会深刻很多。并竟 纸上得来终觉浅,绝知此事要躬行。
相关文章:
今天的文章就到这里了。
你好,我是博主
宁在春
:主页如若在文章中遇到疑惑,请留言或私信,或者加主页联系方式,都会尽快回复。
如若发现文章中存在问题,望你能够指正,不胜感谢。
如果觉得对你有所帮助的话,请点个赞再走吧!