zoukankan      html  css  js  c++  java
  • spring security

    通过配置spring security,可以实现认证和授权两个功能。

    需要实现WebSecurityConfigurerAdapter接口,以下是实现spring security的最简单配置

    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    }

    WebSecurityConfigurerAdapter接口有三个可以重载的方法

    (1)configure(WebSecurity) ,通过重载配置spring security的filter链

    (2)configure(HttpSecurity),通过重载配置,配置如何通过拦截器保护请求

    (3)configure(AuthenticationManagerBuilder),通过重载,配置user-detail服务

    1、需要配置用户存储,然后通过实现configure(AuthenticationManagerBuilder)方法,指定哪些用户(指定用户名、密码、角色、权限)可以通过验证。

    (1)可以配置基于内存的用户存储

    (2)基于数据库的用户存储

    (3)自定义的用户存储,需要实现UserDetailsService接口,该接口只有一个方法:

    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException

    其实质是从数据库中根据用户名,查询其密码和权限列表等,然后封装在已经定义好的UserDetails。其中UserDetails也是一个接口,方法有:

    getAuthorities()
    getPassword()
    getUsername()
    isAccountNonExpired()
    isAccountNonLocked()
    isCredentialsNonExpired()
    isEnabled()

    2、拦截请求,通过实现configure(HttpSecurity),以实现哪些请求需要哪些权限、哪些角色的用户才能访问。

    表单登陆

    protected void configure(HttpSecurity http) throws Exception {
    	http
    		.authorizeRequests()
    			.anyRequest().authenticated()
    			.and()
    		.formLogin()
    			.and()
    		.httpBasic();
    }

    上面的默认配置:

    • 确保我们应用中的所有请求都需要用户被认证

    • 允许用户进行基于表单的认证

    • 允许用户使用HTTP基于验证进行认证

    自动生成的登录页面可以方便应用的快速启动和运行,大多数应用程序都需要提供自己的登录页面。要做到这一点,我们可以更新我们的配置,如下所示:

    protected void configure(HttpSecurity http) throws Exception {
    	http
    		.authorizeRequests()
    			.anyRequest().authenticated() //要求用户进行身份验证并且在我们应用程序的每个URL这样做
    			.and()
    		.formLogin()
    			.loginPage("/login") //指定登录页的路径
    			.permitAll();  //我们必须允许所有用户访问我们的登录页(例如为验证的用户),这个formLogin().permitAll()方法允许基于表单登录的所有的URL的所有用户的访问      
    }
     验证请求

     可以通过给http.authorizeRequests()添加多个子节点来指定多个定制需求到我们的URL。

    protected void configure(HttpSecurity http) throws Exception {
    	http
    		.authorizeRequests()      //http.authorizeRequests()方法有多个子节点,每个macher按照他们的声明顺序执行。                                                          
    			.antMatchers("/resources/**", "/signup", "/about").permitAll()    //我们指定任何用户都可以通过访问的多个URL模式。任何用户都可以访问URL以"/resources/", equals "/signup", 或者 "/about"开头的URL。              
    			.antMatchers("/admin/**").hasRole("ADMIN")      //以 "/admin/" 开头的URL只能由拥有 "ROLE_ADMIN"角色的用户访问。请注意我们使用 hasRole 方法,没有使用 "ROLE_" 前缀.                                
    			.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")   //任何以"/db/" 开头的URL需要用户同时具有 "ROLE_ADMIN" 和 "ROLE_DBA"。和上面一样我们的 hasRole 方法也没有使用 "ROLE_" 前缀.         
    			.anyRequest().authenticated() //尚未匹配的任何URL要求用户进行身份验证                                                  
    			.and()
    		// ...
    		.formLogin();
    }
     
    SecurityContextHolder对象

    最根本的对象是SecurityContextHolder。我们把当前应用程序的当前安全环境的细节存储到它里边了, 它也包含了应用当前使用的主体细节。默认情况下SecurityContextHolder使用ThreadLocal存储这些信息, 这意味着,安全环境在同一个线程执行的方法一直是有效的, 即使这个安全环境没有作为一个方法参数传递到那些方法里。这种情况下使用ThreadLocal是非常安全的,只要记得在处理完当前主体的请求以后,把这个线程清除就行了。当然,Spring Security自动帮你管理这一切了, 你就不用担心什么了。

    有些程序并不适合使用ThreadLocal,因为它们处理线程的特殊方法。比如Swing客户端也许希望Java Virtual Machine里所有的线程 都使用同一个安全环境。SecurityContextHolder可以配置启动策略来指定你希望上下文怎么被存储。对于一个独立的应用程序,你会使用SecurityContextHolder.MODE_GLOBAL策略。其他程序可能也想由安全线程产生的线程也承担同样的安全标识。这是通过使用SecurityContextHolder.MODE_INHERITABLETHREADLOCAL实现。你可以通过两种方式更改默认的SecurityContextHolder.MODE_THREADLOCAL模式。第一个是设置系统属性,第二个是调用SecurityContextHolder的静态方法。大多数应用程序不需要修改默认值,但是如果你想要修改,可以看一下SecurityContextHolder的JavaDocs中的详细信息了解更多。

     
    SecurityContext和Authentication 对象

    我们在SecurityContextHolder内存储目前与应用程序交互的主要细节。Spring Security使用一个Authentication对象来表示这些信息。 你通常不需要创建一个自我认证的对象,但它是很常见的用户查询的Authentication对象。你可以使用以下代码块-从你的应用程序的任何部分-获得当前身份验证的用户的名称,例如:

    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    
    if (principal instanceof UserDetails) {
    String username = ((UserDetails)principal).getUsername();
    } else {
    String username = principal.toString();
    }

    通过调用getContext()返回的对象是SecurityContext接口的实例。这是保存在线程本地存储中的对象。我们将在下面看到,大多数的认证机制以Spring Security返回UserDetails实例为主。

    UserDetailsService对象

    从上面的代码片段中还可以看出一件事,就是你可以从Authentication对象中获得安全主体。这个安全主体就是一个Object。大多数情况下,可以强制转换成UserDetails对象 。 UserDetails是一个Spring Security的核心接口。它代表一个主体,是扩展的,而且是为特定程序服务的。 想一下UserDetails章节,在你自己的用户数据库和如何把Spring Security需要的数据放到SecurityContextHolder里。为了让你自己的用户数据库起作用,我们常常把UserDetails转换成你系统提供的类,这样你就可以直接调用业务相关的方法了(比如 getEmail(), getEmployeeNumber()等等)。

    现在,你可能想知道,我应该什么时候提供这个UserDetails对象呢?我怎么做呢?我想你说这个东西是声明式的,我不需要写任何代码,怎么办?简单的回答是,这里有一个特殊的接口叫UserDetailsService。这个接口里的唯一的一个方法,接收String类型的用户名参数,返回UserDetails:

    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

    这是Spring Security用户加载信息的最常用的方法并且每当需对用户的信息时你会看到它使用的整个框架。

    成功认证后,UserDetails用于构建存储在SecurityContextHolder(详见 以下)的Authentication对象。好消息是,我们提供了一些UserDetailsService的实现,包括一个使用内存映射(InMemoryDaoImpl)而另一个使用JDBC(JdbcDaoImpl)。大多数用户倾向于写自己的,常常放到已有的数据访问对象(DAO)上使用这些实现,表示他们的雇员,客户或其他企业应用中的用户。记住这个优势,无论你用UserDetailsService返回的什么数据都可以通过SecurityContextHolder获得,就像上面的代码片段讲的一样。

    GrantedAuthority

    除了主体,另一个Authentication提供的重要方法是getAuthorities()。这个方法提供了GrantedAuthority对象数组。毫无疑问,GrantedAuthority是赋予到主体的权限。这些权限通常使用角色表示,比如ROLE_ADMINISTRATORROLE_HR_SUPERVISOR。这些角色会在后面,对web验证,方法验证和领域对象验证进行配置。Spring Security的其他部分用来拦截这些权限,期望他们被表现出现。GrantedAuthority对象通常是使用UserDetailsService读取的。

     
    什么是spring security验证
     
    让我们考虑一个大家都很熟悉的标准的验证场景。
    1. 提示用户输入用户名和密码进行登录。

    2. 该系统 (成功) 验证该用户名的密码正确。

    3. 获取该用户的环境信息 (他们的角色列表等).

    4. 为用户建立安全的环境。

    5. 用户可能执行一些操作,这是潜在的保护的访问控制机制,检查所需权限,对当前的安全的环境信息的操作。

    前三个项目构成的验证过程,所以我们将看看这些是如何发生在Spring Security中的。

    1. 用户名和密码进行组合成一个实例UsernamePasswordAuthenticationToken (一个Authentication接口的实例, 我们之前看到的).

    2. 令牌传递到AuthenticationManager实例进行验证。

    3. AuthenticationManager完全填充Authentication实例返回成功验证。

    4. 安全环境是通过调用 SecurityContextHolder.getContext().setAuthentication(…​), 传递到返回的验证对象建立的。

    从这一点上来看,用户被认为是被验证的。让我们看看一些代码作为一个例子:

    import org.springframework.security.authentication.*;
    import org.springframework.security.core.*;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.context.SecurityContextHolder;
    
    public class AuthenticationExample {
    private static AuthenticationManager am = new SampleAuthenticationManager();
    
    public static void main(String[] args) throws Exception {
    	BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
    
    	while(true) {
    	System.out.println("Please enter your username:");
    	String name = in.readLine();
    	System.out.println("Please enter your password:");
    	String password = in.readLine();
    	try {
    		Authentication request = new UsernamePasswordAuthenticationToken(name, password);//用户名和密码进行组合成一个实例UsernamePasswordAuthenticationToken
    		Authentication result = am.authenticate(request);//令牌传递到AuthenticationManager实例进行验证
    		SecurityContextHolder.getContext().setAuthentication(result);//安全环境是通过调用 SecurityContextHolder.getContext().setAuthentication(…​), 传递到返回的验证对象建立的。
    break; } catch(AuthenticationException e) { System.out.println("Authentication failed: " + e.getMessage()); } } System.out.println("Successfully authenticated. Security context contains: " + SecurityContextHolder.getContext().getAuthentication()); } } class SampleAuthenticationManager implements AuthenticationManager { static final List<GrantedAuthority> AUTHORITIES = new ArrayList<GrantedAuthority>(); static { AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));//设置权限列表 } public Authentication authenticate(Authentication auth) throws AuthenticationException { if (auth.getName().equals(auth.getCredentials())) { return new UsernamePasswordAuthenticationToken(auth.getName(), auth.getCredentials(), AUTHORITIES);//设置用户名、密码、权限列表创建Authentication对象 } throw new BadCredentialsException("Bad Credentials"); } }

    在这里我们已经写了一个小程序,要求用户输入一个用户名和密码并执行上述序列。这个AuthenticationManager我们这里将验证用户的用户名和密码将其设置成一样的,它给每一个用户分配一个单一的角色。从上面输出的将是类似的东西:

    Please enter your username:
    bob
    Please enter your password:
    password
    Authentication failed: Bad Credentials
    Please enter your username:
    bob
    Please enter your password:
    bob
    Successfully authenticated. Security context contains: 
    org.springframework.security.authentication.UsernamePasswordAuthenticationToken@441d0230: 
    Principal: bob; Password: [PROTECTED]; 
    Authenticated: true; Details: null; 
    Granted Authorities: ROLE_USER

    请注意,你通常不需要写任何这样的代码。这个过程通常会发生在内部,以一个web认证过滤器为例,我们刚刚在这里的代码显示,在Spring Security中究竟是什么构成了验证的问题,有一个相对简单的答案。用户验证时,SecurityContextHolder包含一个完全填充的Authentication对象的用户进行身份验证。

    在Web应用程序中的身份验证

    ExceptionTranslationFilter

    ExceptionTranslationFilter是一个Spring Security过滤器,用来检测是否抛出了Spring Security异常。这些异常会被AbstractSecurityInterceptor抛出,它主要用来提供验证服务。

    AuthenticationEntryPoint

    每个主要验证系统会有它自己的AuthenticationEntryPoint实现

    Storing the SecurityContext between requests

    根据不同的应用程序类型,在用户操作的过程中需要有合适的策略来保存security信息。在一个典型的web应用中,一个用户登录系统之后就会被一个特有的session Id所唯一标识,服务器会将session作用期间的principal数据保存在缓存中。在Spring Security中,保存SecurityContext的任务落在了SecurityContextPersistenceFilter身上,它默认将上下文当做HttpSession属性保存在HTTP请求中,并且将每一个请求的上下文保存在SecurityContextHolder中,最重要的功能,是在请求结束之后,清理SecurityContextHolder。你不需要处于安全的目的直接和HttpSession打交道。在这里仅仅只是不需要那样做-总是使用SecurityContextHolder来代替HttpSession

    Spring Security的访问控制(授权)

    负责Spring Security访问控制决策的主要接口是AccessDecisionManager。它有一个decide方法,它需要一个Authentication对象请求访问。

    安全和AOP建议

    spring security利用AOP,为方法的调用创建了环绕通知。

     AbstractSecurityInterceptor

     Spring Security支持的每个安全对象类型都有它自己的类型,他们都是AbstractSecurityInterceptor的子类。很重要的是,如果主体是已经通过了验证,在AbstractSecurityInterceptor被调用的时候,SecurityContextHolder将会包含一个有效的Authentication

     Web Application Security

     The Security Filter Chain

     spring security框架是基于标准servlet过滤器的。filter chain中的每个过滤器都有其对应的职责,并且根据实际应用需要可以进行添加或者删除过滤器。由于过滤器之间的依赖关系,所以过滤器的顺序是很重要的。

    DelegatingFilterProxy

    当使用过滤器时,需要在web.xml中定义它们,否则会被servlet容器忽略。过滤器类也可以时spring应用上下文中的bean。DelegatingFilterProxy提供了web.xml和应用上下文的链接。

    当用DelegatingFilterProxy时,可以在web.xml中这样配置:

    <filter>
    <filter-name>myFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    
    <filter-mapping>
    <filter-name>myFilter</filter-name>
    <url-pattern>/*</url-pattern>
    </filter-mapping>

    过滤器实际上是一个DelegatingFilterProxy,并不是真正的实现过滤逻辑的过滤器类。DelegatingFilterProxy所做的是将过滤器的方法委托给从Spring应用程序上下文获得的bean。过滤器的bean必须实现javax.servlet.Filter。

    FilterChainProxy

     web application security应该委托给FilterChainProxy的一个实例。理论上可以在上下文中(在web.xml中)声明所需要的过滤器类,但是这样会是web.xml文件变得繁杂。FilterChainProxy允许我们提供单独的入口,并完全处理用于管理web安全bean的应用程序上下文文件。下面是一个例子:

    <bean id="filterChainProxy" class="org.springframework.security.web.FilterChainProxy">
    <constructor-arg>
        <list>
        <sec:filter-chain pattern="/restful/**" filters="
            securityContextPersistenceFilterWithASCFalse,
            basicAuthenticationFilter,
            exceptionTranslationFilter,
            filterSecurityInterceptor" />
        <sec:filter-chain pattern="/**" filters="
            securityContextPersistenceFilterWithASCTrue,
            formLoginFilter,
            exceptionTranslationFilter,
            filterSecurityInterceptor" />
        </list>
    </constructor-arg>
    </bean>

    命名空间filter-chain用来建立过滤器链,它匹配了特定形式的url,filters属性是所有用到的过滤器。过滤器的调用顺序将按照其在filters属性中声明的顺序执行。通过此配置可以为特定的url定义一组过滤器链。

     可以注意到,以上代码声明了两次SecurityContextPersistenceFilter(ASC是allowSessionCreation的缩写,是SecurityContextPersistenceFilter的一个属性)。

    Filter Ordering

     过滤器的执行顺序如下:

    • ChannelProcessingFilter,因为它可能会被重定向到不同的协议。
    • SecurityContextPersistenceFilter, 在请求开始的时候,SecurityContext会在SecurityContextHolder中建立,当请求结束时,SecurityContext任何的变化都会被复制到HttpSession中。
    • ConcurrentSessionFilter
    • Authentication processing mechanisms - UsernamePasswordAuthenticationFilter, CasAuthenticationFilter, BasicAuthenticationFilter etc - 可以使SecurityContextHolder 包含一个有效的Authentication
    • SecurityContextHolderAwareRequestFilter,可以用它把HttpServletRequestWrapper放到servlet容器
    • The JaasApiIntegrationFilter, 如果SecurityContextHolder中有JaasAuthenticationToken,FilterChain将作为实例在JaasAuthenticationToken被处理
    • RememberMeAuthenticationFilter, so that if no earlier authentication processing mechanism updated the SecurityContextHolder, and the request presents a cookie that enables remember-me services to take place, a suitable remembered Authentication object will be put there
    • AnonymousAuthenticationFilter, so that if no earlier authentication processing mechanism updated the SecurityContextHolder, an anonymous Authentication object will be put there
    • ExceptionTranslationFilter, 用来捕获异常以至于http响应错误能够被返回并且AuthenticationEntryPoint能够被执行。
    • FilterSecurityInterceptor, 当被拒绝时,来保护应用程序和抛出异常。

    Core Security Filters

    FilterSecurityInterceptor

    FilterSecurityInterceptor 被用来处理http访问的安全性。它需要AuthenticationManagerAccessDecisionManager作为它的属性值。还可以为不同的url请求定义不同的安全性。

    <bean id="filterSecurityInterceptor"
        class="org.springframework.security.web.access.intercept.FilterSecurityInterceptor">
    <property name="authenticationManager" ref="authenticationManager"/>
    <property name="accessDecisionManager" ref="accessDecisionManager"/>
    <property name="securityMetadataSource">
        <security:filter-security-metadata-source>
        <security:intercept-url pattern="/secure/super/**" access="ROLE_WE_DONT_HAVE"/>
        <security:intercept-url pattern="/secure/**" access="ROLE_SUPERVISOR,ROLE_TELLER"/>
        </security:filter-security-metadata-source>
    </property>
    </bean>

     ExceptionTranslationFilter

    过滤器链的顺序中ExceptionTranslationFilter在FilterSecurityInterceptor之前,但是ExceptionTranslationFilter并不处理安全事务,而是封装其他过滤器抛出的异常,以返回合适的http响应。

    <bean id="exceptionTranslationFilter"
    class="org.springframework.security.web.access.ExceptionTranslationFilter">
    <property name="authenticationEntryPoint" ref="authenticationEntryPoint"/>
    <property name="accessDeniedHandler" ref="accessDeniedHandler"/>
    </bean>
    
    <bean id="authenticationEntryPoint"
    class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
    <property name="loginFormUrl" value="/login.jsp"/>
    </bean>
    
    <bean id="accessDeniedHandler"
        class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
    <property name="errorPage" value="/accessDenied.htm"/>
    </bean>

      ExceptionTranslationFilter的另一个作用是在调用AuthenticationEntryPoint之前保存当前的请求。比如说,用户登陆表单,然后利用SavedRequestAwareAuthenticationSuccessHandler,用户被重定向到原始的url。RequestCache封装了HttpServletRequest实例所需要的信息。当HttpSessionRequestCache被启用时,请求信息会存储在HttpSession。当被重定向到原始的url时,RequestCacheFilter负责恢复在缓存中存储的请求。

    AuthenticationEntryPoint

    如果未通过认证的用户试图访问受保护的资源,那么AuthenticationEntryPoint将会被调用,会抛出AuthenticationException或者AccessDeniedException异常。这会提示没有通过验证的用户需要继续认证。以上示例的LoginUrlAuthenticationEntryPoint会重定向到一个登陆页面。

    AccessDeniedHandler

     如果用户已经通过验证但是又试图访问受保护的资源(此用户没有此资源的访问权限)会发生什么呢。正常情况下用户被禁止这样做,比如,对于普通用户,管理员界面的链接应该被隐藏。但是不能完全依赖隐藏链接的方式来确保应用程序的安全性。应该在web层做一些基于url或者方法级别的安全限制。

    如果已经被认证的用户尝试访问受保护的资源,但是该用户没有此资源的访问权限,就会抛出AccessDeniedException异常。这时ExceptionTranslationFilter会调用AccessDeniedHandler。以上示例中,默认实现类AccessDeniedHandlerImpl会返回403(Forbidden),可以配置一个错误页返回给客户端,也可以设置调用controller的某个方法。当然自己也可以实现AccessDeniedHandler。

     SecurityContextPersistenceFilter

    SecurityContextPersistenceFilter有两个任务,第一,它负责在http请求时存储SecurityContext,第二,在一个请求完成时,清理SecurityContextHolder。由于一个线程可能被替换到servlet容器的连接池中,所以当一个请求完成时,需要清理ThreadLocal(ThreadLocal中存储了session,session中存放了security context信息?)。因为这个线程在稍后可能会被使用,这时就会用错误的凭证执行操作。

    SecurityContextRepository

     Spring Security 3.0后,加载和存储security context的工作委托给一个单独的接口SecurityContextRepository。

    public interface SecurityContextRepository {
    
    SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder);
    
    void saveContext(SecurityContext context, HttpServletRequest request,
            HttpServletResponse response);
    }

     HttpRequestResponseHolder是一个请求和相应容器,允许写一个包装类替换这个实现。返回值将会传递给过滤器链。

    默认的实现是HttpSessionSecurityContextRepository,将security context存储在HttpSession中。最重要的属性是allowSessionCreation属性,默认值为true,如果需要存储用户的security context,就会创建一个session,用来存储security context(存储会发生在验证后或者security context的值更改时)。如果不需要,则设置为false:

    <bean id="securityContextPersistenceFilter"
        class="org.springframework.security.web.context.SecurityContextPersistenceFilter">
    <property name='securityContextRepository'>
        <bean class='org.springframework.security.web.context.HttpSessionSecurityContextRepository'>
        <property name='allowSessionCreation' value='false' />
        </bean>
    </property>
    </bean>

    Alternatively you could provide an instance of NullSecurityContextRepository, a null object implementation, which will prevent the security context from being stored, even if a session has already been created during the request.

     你也可以创建NullSecurityContextRepository的一个实例,即使在请求期间建立了session,它也会阻止security context被存储。

     UsernamePasswordAuthenticationFilter

     UsernamePasswordAuthenticationFilter是伴随着命名空间<http>而自动建立的,并且不能被替换。UsernamePasswordAuthenticationFilter是实现认证机制的过滤器之一。命名空间<form-login>提供了它的实现。配置UsernamePasswordAuthenticationFilter需要以下几个步骤:

    (1)配置LoginUrlAuthenticationEntryPoint,并且配置登陆页的url,并且把它放在ExceptionTranslationFilter前边。

    (2)实现登录页(jsp或者MVC controller)

    (3)在上下文中配置UsernamePasswordAuthenticationFilter的一个实例

    (4)向过滤器链代理中添加这个过滤器。

    登陆表单包括username和password,并且发送到这个过滤器监听的URL(默认是 /login),基本的配置像下边这样:

    <bean id="authenticationFilter" class=
    "org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter">
    <property name="authenticationManager" ref="authenticationManager"/>
    </bean>

     验证成功或者失败流程:

    UsernamePasswordAuthenticationFilter过滤器会让AuthenticationManager来处理验证请求。AuthenticationSuccessHandler和AuthenticationFailureHandler控制了验证的成功和失败。可以通过配置来定制具体的验证过程。也有一些标准的实现,比如SimpleUrlAuthenticationSuccessHandler, SavedRequestAwareAuthenticationSuccessHandler, SimpleUrlAuthenticationFailureHandler, ExceptionMappingAuthenticationFailureHandlerDelegatingAuthenticationFailureHandler。

     如果认证成功,Authentication实例将会被放置在SecurityContextHolder中。同时AuthenticationSuccessHandler将会被调用,以来重定向。默认SavedRequestAwareAuthenticationSuccessHandler会被调用,这意味着用户会被重定向到登陆前的目标界面。

     
  • 相关阅读:
    Discuz热搜在哪里设置?
    Discuz如何设置帖子隐藏回复可见或部分可见方法
    新版Discuz!应用中心接入教程(转)
    Diszuz管理面版被锁怎么办?
    vs 2019 调试无法查看变量
    Google Docs 的格式刷快捷键
    chrome 的常用快捷键
    Activiti 数据库表结构
    activiti 报 next dbid
    尚硅谷Java基础_Day2
  • 原文地址:https://www.cnblogs.com/BonnieWss/p/11149553.html
Copyright © 2011-2022 走看看