SECURITY

Spring Security框架

Spring Security框架主要解决了认证授权的相关问题。

添加依赖

在Spring Boot项目中,需要使用Spring Security时,需要添加spring-boot-starter-security依赖。

当项目中添加此依赖后,默认会执行一系列的自动配置,将:

  • 当前项目中所有的访问,都是必须先登录才允许的

    • 未登录时,将自动重定向到 /login,当登录成功后,将自动重定向到此前访问的页面,或主页
    • 访问 /logout 可以退出登录
    • 如果希望某些路径不需要登录就可以访问,可以自定义Spring Security的配置类,将这些路径配置为“白名单”
  • 启动项目的过程中,会生成随机的临时密码,用户名为user

密码加密

在开发实践中,所有的用户密码都必须加密之后,再存储到数据库中。

用户的原始密码(例如1234)通常称之为原文明文,加密后得到的结果(例如lkjfadshfdslafndshdsfaj)通常称之为密文

在处理加密时,通常应该选取消息摘要算法对用户的密码进行处理!

注意:不可以使用加密算法对密码进行加密并存储,通常,加密算法是用于保障传输过程的安全的!

消息摘要算法是不可逆的算法,是适合对密码进行加密的!

消息摘要算法的主要特点有:

  • 同一种算法,无论消息长度多少,摘要的长度是固定的
  • 当消息相同时,摘要必然相同
  • 当消息不同时,摘要理论上不会相同(有概率是相同的)
    • 消息的长度是无限的,摘要的长度是有限且固定的

需要注意:理论上有n种不同的消息对应同一个摘要,但是,出现这样的现象的概率极低!

典型的消息摘要算法有:

  • MD系列(Message Digest):MD2 / MD4 / MD5

    • MD系列的全部是128位算法
  • SHA家族(Secure Hash Algorithm):SHA-1 / SHA-256 / SHA-384 / SHA-512
    • SHA-1是160位算法,其它则是与算法名称对应,例如SHA-256就是256位算法
  • SM3(国家加密算法)
    • SM3是256位算法

在Spring Boot中,spring-boot-starter依赖项就包含DigestUtils工具类,可以简便的实现MD5算法的处理,例如:

package cn.tedu.csmall.passport;import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;public class MessageDigestTests {@Testpublic void testMd5() {String rawPassword = "123456";String encodedPassword = DigestUtils.md5DigestAsHex(rawPassword.getBytes());System.out.println("rawPassword = " + rawPassword);System.out.println("encodedPassword = " + encodedPassword);// 123456 >>> e10adc3949ba59abbe56e057f20f883e}}

如果想要使用其它消息摘要算法,可以自行在项目中添加commons-codec依赖项,此依赖中也有一个名为DigestUtils的工具类,提供了多种算法的API。

由于消息算法的特点包括“消息相同,摘要必然相同”,所以,在互联网上有一些平台记录了消息与摘要的对应关系,记录在数据库,可以根据摘要进行反向查询,从而得知摘要对应的消息!但是,由于这些平台能够记录的对应关系非常有限,可以使用更复杂的消息,大概率是没有被这些平台收录的,则不会被这些平台反向查询出原消息!

换言之,只要原始密码足够复杂,则不会被这些平台“破解”。

但是,某些场景中并不支持使用复杂的消息(密码),也有些用户不愿意使用复杂的原始密码,则很容易被穷举出消息与摘要的对应列表,为解决此问题,应该在加密过程中使用“盐”,盐的本质就是一个字符串,其作用是使得被运算数据变得更加复杂,例如:

@Test
public void testMd5() {String salt = "kjkhglkjjg";String rawPassword = "123456";//                                                   123456kjkhglkjjgString encodedPassword = DigestUtils.md5DigestAsHex((rawPassword + salt).getBytes());System.out.println("rawPassword = " + rawPassword);System.out.println("encodedPassword = " + encodedPassword);
}

而盐值的具体值并没有明确的要求,包括其使用方式也没有明确的要求!

另外,还可以尝试多重加密,即循环调用以上算法。

所以,为了提高密码的安全性:

  • 强制要求使用强度更高的密码
  • 加盐
  • 多重加密
  • 使用更安全的算法
  • 综合使用以上做法

关于盐的补充:通常,可以使用随机的盐值,则即使完全相同的原始密码,得到的加密结果也完全不同,例如:

@Test
public void testMd5() {for (int i = 0; i < 5; i++) {String salt = UUID.randomUUID().toString();String rawPassword = "123456";String encodedPassword = DigestUtils.md5DigestAsHex((salt + rawPassword).getBytes());System.out.println("rawPassword = " + rawPassword);System.out.println("encodedPassword = " + encodedPassword);System.out.println();}
}

以上运行结果例如(每次都不同):

rawPassword = 123456
encodedPassword = 678408c66bef83edf72b11ad5b505161rawPassword = 123456
encodedPassword = 99c3da1ef1d1e9ea976c91a00af0b4c0rawPassword = 123456
encodedPassword = 52c809ab1ef18607c0f357d1caa4082frawPassword = 123456
encodedPassword = faf506f5d7a8d5109fc24d4c700fb136rawPassword = 123456
encodedPassword = e89b5401bfbd233e24cb3862425ccdb8

需要注意的是,一旦使用随机的盐值,则必须将此随机的盐值记录下来(可以在添加数据时,在数据表中使用专门的字段进行记录,或者,将盐址和加密结果合并成1个字符串作为记录下来的密码),否则,在后续的验证密码时,将无法运算得到匹配的结果!

使用示例:

rawPassword = 123456
salt = 4da1ba18-e9c5-4adc-bc0e-3768aca841ad
encodedPassword = ef3bcab34967ab87d9a3002366439898得到最终密码(盐值拼接密文):
4da1ba18-e9c5-4adc-bc0e-3768aca841adef3bcab34967ab87d9a3002366439898

当使用了Spring Security框架后,此框架中还包含了BCryptPasswordEncoder类,此类可以使用BCrypt算法对密码进行处理,调用此类对象的encode()方法即可实现加密,调用matches()方法就可以实现将原文和密文进行对比!(这2个方法都是在PasswordEncoder接口中定义的)

在Spring Security中放行某些请求

默认情况下,Spring Security要求所有请求都是必须登录后才可以访问的,当需要放行某些请求时,可以将这些请求路径配置为“白名单”。

需要自定义配置类,继承自WebSecurityConfigurerAdapter类,重写其中的configurer(HttpSecurity)方法:

package cn.tedu.csmall.passport.config;import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder() {log.debug("创建密码编码器组件:BCryptPasswordEncoder");return new BCryptPasswordEncoder();}@Overrideprotected void configure(HttpSecurity http) throws Exception {// 在配置路径时,可以使用星号作为通配符// 使用 /* 只能匹配1层级路径,例如 /user 或 /brand,不可以匹配多层级,例如不可以匹配到 /user/list// 使用 /** 可以匹配若干层级路径http.csrf().disable(); // 禁用防止跨域访问,如果无此配置,白名单路径的异步访问也会出现403错误http.authorizeRequests() // 请求需要被授权才可以访问.antMatchers("/**") // 匹配某些路径.permitAll(); // 允许直接访问(不需要经过认证和授权)}
}

使用数据库中的账号实现登录

在Spring Security,使用默认的登录时(默认存在的/login页面),默认情况下使用user作为用户名,使用启动时生成的临时密码。在处理过程中,也可以使其使用数据库中的账号进行登录,Spring Security会自动获取在输入框中输入的用户名、密码,然后,会自动调用UserDetailsService接口类型对象的UserDetails loadUserByUsername(String username)方法,并获取返回的UserDetails对象,此对象中应该包含密码的密文值,接下来,Spring Security会自动将登录界面中获取的密码原文进行加密,并与UserDetails中的密文进行对比,以判断是否可以成功登录。

在测试之前,应该先禁用(注释掉相关代码)Spring Security中的configurer(HttpSecurity)方法,否则将不会显示 /login 页面。

接下来,在项目的根包下自定义类security.UserDetailsServiceImpl类,实现UserDetailsService接口,并重写接口中的loadUserByUsername()方法:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security自动根据用户名【{}】查询用户详情", s);return null;}}

一旦编写了UserDetailsService接口的实现类,并将此类由Spring创建对象,则Spring Security会自动装配此类的对象,在后续启动项目时,将不再生成默认的随机密码,且默认的用户名user将不再可用。

可以在此方法中测试返回某个账号信息,例如:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security自动根据用户名【{}】查询用户详情", s);// 以下是临时使用的代码if ("wangkejing".equals(s)) {UserDetails userDetails = User.builder().username("wangkejing") // 用户名.password("$2a$10$XzUcx6Oag7n0tNKhBAGQEe5sFv9Jow9Fa0020UiWkfajUue7bmjz6") // 密码,此密文的原文是123456.disabled(false) // 账号是否禁用.accountLocked(false) // 账号是否锁定.accountExpired(false) // 账号是否过期.credentialsExpired(false) // 认证是否过期.authorities("临时给出任意字符串") // 【必须】此账号的权限信息.build();return userDetails;}return null;
}

如果此时重启项目,并通过 /login 测试登录:

  • 当用户名不是wangkejing时,浏览器将提示UserDetailsService returned null,且IntelliJ IDEA控制台会有异常信息
  • 当用户名是wangkejing但密码不是123456时,在浏览器将提示“用户名或密码错”
  • 当用户名是wangkejing且密码是123456时,将登录成功(登录后的跳转可能404)

如果要改为通过数据库中的账号来实现登录,则在以上代码中,替换为“根据用户名查询管理员信息,如果存在此用户名对应的数据,则将查询到的管理员信息封装到UserDetails中并返回”即可。

所以,接下来需要执行的任务:

  • 在根包下创建pojo.vo.AdminLoginVO类,此类中至少包括:用户名、密码

  • AdminMapper接口中添加抽象方法AdminLoginVO getByUsername(String username);

  • AdminMapper.xml中配置以上抽象方法映射的SQL

    • select username, password from ams_admin where username=?
      
  • AdminMapperTests中进行测试

  • UserDetailsServiceImpl中,自动装配AdminMapper对象,然后调用以上查询功能,并将查询到的结果中的信息封装到返回的UserDetails中去,如果未查询到有效记录,可以直接返回null

自定义处理登录的流程

默认情况下,Spring Security有默认的登录页,输入用户名、密码后,是由Spring Security自动接收登录请求,然后进行处理的,如果登录成功,会自动跳转到此前访问的页面,如果登录失败,会将错误信息提示到默认的登录页上。

以上这套流程不适用于开发实践,因为:

  • 这不是前后端分离的做法(服务器端处理了登录后,不响应JSON结果)
  • 不便于处理细节,例如使用Validation框架验证请求参数的格式

要解决此问题,应该:像开发其它数据处理流程一样的做法,只不过,在自定义的Service实现过程中,通过Spring Security的机制来验证用户名和密码即可。

首先,需要使得控制器可以接收客户端提交的登录请求,需要:

  • 在根包下创建pojo.dto.AdminLoginDTO类,在此类中封装登录请求的相关参数,例如:用户名,密码

  • AdminController中添加处理登录请求的方法:

    • @PostMapping("/login")
      public JsonResult login(AdminLoginDTO adminLoginDTO) {// 通过日志简单的输出return JsonResult.ok();
      }
      
  • 完成后,可以Knife4j中测试访问,且响应结果永远是成功(目前还没有真正的实现登录)

然后,需要在Service中准备处理登录,需要:

  • IAdminService中声明:void login(AdminLoginDTO adminLoginDTO);
  • AdminServiceImpl中重写以上方法,实现细节可暂时留空
  • AdminController处理登录时调用Service组件的此方法

关于在Service中处理登录的细节,应该使用Spring Security中的AuthenticationManager对象来执行Spring Security的认证过程(后续保存用户信息、授权访问等都需要)。

如果需要得到AuthenticationManager,需要在Spring Security的配置类(自定义的SecurityConfiguration类)中重写authenticationManager()方法,此方法可以返回AuthenticationManager对象,则在重写在方法上添加@Bean注解,可以使得Spring会自动调用此方法,并将返回结果保存在Spring容器中:

@Bean // 必须添加此注解
@Override
protected AuthenticationManager authenticationManager() throws Exception {return super.authenticationManager();
}

然后,回到业务实现类中,自动装配AuthenticationManager对象,在具体实现时,调用此对象的authenticate()方法,即可实现Spring Security的认证,此方法的参数可使用UsernamePasswordAuthenticationToken来封装用户名和密码:

@Override
public void login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理管理员登录的业务,参数:{}", adminLoginDTO);// 调用AuthenticationManager执行Spring Security的认证Authentication authentication= new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());authenticationManager.authenticate(authentication);log.debug("登录成功!");
}

以上代码的执行流程大致是:

请求 ==> Controller ==> Service ==> AuthenticationManager ==> UserDetailsServiceImpl ==> Mapper

关于Session

HTTP协议是无状态的协议,从协议本身来说,通信过程中并不会记录用户的相关信息,如果某用户第1次访问了服务器后,第2次再次访问时,服务器并不会知道这是与第1次访问时是同一个用户!

在开发实践时,需要明确用户的身份,所以,各编程语言都提供了基于Session的处理机制,Session是服务器端程序维护的一个类似Map的数据,每个客户端都有一个唯一的Key对应到此处的某个值!所以,各个客户端的访问时,都可以向自己对应的Session数据中存入数据,后续,也可以取出之前存入的数据,例如,可以在登录成功后将用户的id存入到Session中,后续,就可以根据“Session中有没有此id”来判断用户是否登录了,并根据存入的用户id来识别用户的身份。

在Session的具体使用过程中,当某个客户端第1次向服务器端发出请求时,并没有所谓的Map的Key,则服务器会会自动生成一个Key响应到客户端去,客户端会自动将此Key保存下来,并在后续每次发出请求时都自动携带这个Key!在此过程中,客户端还会使用Cookie技术将Key保存在客户端!

Session中的Key本身上都是UUID值,本身并没有具体的信息含义,只是具有唯一性,使得各客户端访问服务器端的Session时不会发生冲突。

目前,并不推荐使用Session技术来处理识别用户的身份,因为在集群架构中,同一个用户的多次请求可能是由集群中不同的服务器进行处理,而Session是存储在服务器内存中的数据,直接使用的话,就会无法识别用户的身份!

关于Token

Token可以称之为“票据”、“令牌”,其最大的特点是类似于Session的Key这样的数据中是体现信息含义的!相当于“火车票”,票上是可以体现一些数据的,服务器就相当于“车站”,不同的车站都有相同的验票机制,能够识别“火车票”的真伪,并从中获取某些信息。

关于JWT

JWT = Json Web Token,相比普通Token,它使用JSON封装更多的信息含义。

为了保证JWT数据在网络上传输时的安全,JWT本身是一组加密后的数据,通常,有相关的工具包来负责生成JWT并解析JWT中的数据。

可以选择使用jjwt工具包来实现,在项目中,添加依赖项:

<!-- JJWT(Java JWT) -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version>
</dependency>

可以创建测试类,测试生成JWT解析JWT

public class JwtTests {// 密钥String secretKey = "jfdsakjdsfk%&JFDsfFDFADSFhj875421dsafhjafdsfdsalkjafdsafds";@Testpublic void testGenerateJwt() {// 准备ClaimsMap<String, Object> claims = new HashMap<>();claims.put("id", 9527);claims.put("name", "刘老师");// 准备过期时间:1分钟Date expirationDate = new Date(System.currentTimeMillis() + 1 * 60 * 1000);// JWT的组成部分:Header(头)、Payload(载荷)、Signature(签名)String jwt = Jwts.builder()// Header:用于配置算法与此结果数据的类型// 通常配置2个属性:typ(类型)、alg(算法).setHeaderParam("typ", "jwt").setHeaderParam("alg", "HS256")// Payload:用于配置需要封装到JWT中的数据.setClaims(claims).setExpiration(expirationDate)// Signature:用于指定算法与密钥(盐).signWith(SignatureAlgorithm.HS256, secretKey).compact();System.out.println(jwt);// eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9// .// eyJuYW1lIjoi5YiY6ICB5biIIiwiaWQiOjk1MjcsImV4cCI6MTY1NzYxOTY2Nn0// .// kDW_hgQKbBb01WA5kQeMaxY8Fc_H2Yao2DdFABlbuiw}@Testpublic void testParseJwt() {String jwt = "eyJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi5YiY6ICB5biIIiwiaWQiOjk1MjcsImV4cCI6MTY1NzY3NTExNX0.yMG3xL4b2SCNjaqwrIHB3tfA9HHmkiiLzpuYzJCSlog";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object id = claims.get("id");Object name = claims.get("name");System.out.println("id=" + id);System.out.println("name=" + name);}}

解析JWT时,可能会出现一些异常,例如:

  • 当JWT数据过期时:

    io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-07-13T09:18:35Z. Current time: 2022-07-13T09:27:35Z, a difference of 540694 milliseconds.  Allowed clock skew: 0 milliseconds.
    
  • 当生成和解析使用的密钥不一致时,或JWT数据的最后一部分被恶意篡改时:

    io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
    
  • 当JWT数据的第1部分被恶意篡改时:

    io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"t{�:"jwt","alg":"HS256"}
    

在项目中使用JWT

在项目中使用JWT时,通常需要关注的问题:

  • 什么时候生成JWT:通常是登录成功之后,将生成JWT,且会将JWT响应到客户端
  • 客户端什么时候携带JWT来访问服务器端:服务器端不关心
  • 什么时候检查JWT:?????

向客户端响应JWT

当需要向客户端响应JWT时,需要:

  • AdminServiceImpllogin()中,获取authenticate()返回的结果,将此结果转换成User类型,即可从此User类型中获取当初在UserDetailsService中存入的数据,然后,将必要的部分取出(暂时为username),将其生成为JWT数据(参考测试类,暂不考虑封装工具类)
  • IAdminService 接口中将login()的返回值改为String
  • AdminServiceImpl类中也将login()的返回值改为String,并返回JWT数据
  • AdminController处理登录的方法中,调用Service组件的方法时获取返回值,并将此返回值封装到响应结果中

关于AdminServiceImpl中的实现代码:

@Override
public String login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理管理员登录的业务,参数:{}", adminLoginDTO);// 调用AuthenticationManager执行Spring Security的认证Authentication authentication= new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());Authentication loginResult = authenticationManager.authenticate(authentication);// 以上调用的authenticate()方法是会抛出异常的方法,如果还能执行到此处,则表示用户名与密码是匹配的log.debug("登录成功!认证方法返回:{} >>> {}", loginResult.getClass().getName(), loginResult);// 从认证结果中获取Principal,本质上是User类型,且是UserDetailsService中loadUserByUsername()返回的结果log.debug("尝试获取Principal:{} >>> {}", loginResult.getPrincipal().getClass().getName(), loginResult.getPrincipal());User user = (User) loginResult.getPrincipal();String username = user.getUsername();log.debug("登录成功的用户名:{}", username);// 应该在此处生成JWT数据,向JWT中存入:id(暂无), username, 权限(暂无)Map<String, Object> claims = new HashMap<>();claims.put("username", user.getUsername());Date expiration = new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000);String jwt = Jwts.builder().setHeaderParam("typ", "jwt").setHeaderParam("alg", "HS256").setClaims(claims).setExpiration(expiration).signWith(SignatureAlgorithm.HS256, "lkjfdslkjafds8iufnmdsfadsa").compact();log.debug("生成JWT数据:{}", jwt);return jwt;
}

关于客户端携带JWT数据

当客户端尝试访问需要认证才能请求的资源时,客户端应该携带JWT数据,而服务器端应该对JWT数据进行获取、检查、解析等处理。

当客户端携带JWT时,通常会将JWT数据放在请求头(Request Header)中的Authorization属性中,并且,通常,服务器端的程序都会设计为从请求头中的Authorization属性中获取JWT数据。

服务器端检查JWT

由于许多不同的请求都需要检查JWT,所以,不会在控制器中处理JWT!

通常,应该在过滤器组件中检查JWT!

  • 过滤器是Java服务器端程序(无论你使用什么框架)中最早接收到客户端请求的组件,且所有请求都会经过过滤器才会执行到控制器
  • Xxxxx

需在自定义过滤器类:

/*** <p>处理JWT的过滤器</p>** <p>此过滤器将尝试获取请求中的JWT数据,如果存在有效数据,将尝试解析,</p>* <p>然后,将解析得到的结果存入到Spring Security的上下文中,</p>* <p>以至于Spring Security框架中的其它组件能够从上下文中获取到用户的信息,</p>* <p>从而完成后续的授权访问。</p>*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {// 最终,过滤器可以选择“阻止”或“放行”// 如果选择“阻止”,则后续的所有组件都不会被执行// 如果选择“放行”,会执行“过滤器链”中剩余的部分,甚至继续向后执行到控制器等组件@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException {// 此方法是任何请求都会执行的方法log.debug("执行JwtAuthorizationFilter");// 清除Security的上下文// 如果不清除,只要此前存入过信息,即使后续不携带JWT,上下文中的登录信息依然存在SecurityContextHolder.clearContext();// 从请求头中获取JWTString jwt = request.getHeader("Authorization");log.debug("从请求头中获取的JWT数据:{}", jwt);// 先判断是否获取到了有效的JWT数据,如果无JWT数据,直接放行if (!StringUtils.hasText(jwt)) {log.debug("请求头中的JWT数据是无效的,直接放行");filterChain.doFilter(request, response);return;}// 如果获取到了有效的JWT值,则尝试进行解析Claims claims = Jwts.parser().setSigningKey("lkjfdslkjafds8iufnmdsfadsa").parseClaimsJws(jwt).getBody();Object username = claims.get("username");log.debug("从JWT中解析得到用户名:{}", username);// TODO 临时:准备用户权限GrantedAuthority authority = new SimpleGrantedAuthority("1");List<GrantedAuthority> authorities = new ArrayList<>();authorities.add(authority);// 当解析成功后,应该将相关数据存入到Spring Security的上下文中Authentication authentication= new UsernamePasswordAuthenticationToken(username, null, authorities);SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authentication);// 以下代码将执行“放行”filterChain.doFilter(request, response);}}

并在配置类中添加配置:

package cn.tedu.csmall.passport.config;import cn.tedu.csmall.passport.filter.JwtAuthorizationFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtAuthorizationFilter jwtAuthorizationFilter;@Beanpublic PasswordEncoder passwordEncoder() {log.debug("创建密码编码器组件:BCryptPasswordEncoder");return new BCryptPasswordEncoder();}@Bean@Overrideprotected AuthenticationManager authenticationManager() throws Exception {return super.authenticationManager();}@Overrideprotected void configure(HttpSecurity http) throws Exception {// 在配置路径时,可以使用星号作为通配符// 使用 /* 只能匹配1层级路径,例如 /user 或 /brand,不可以匹配多层级,例如不可以匹配到 /user/list// 使用 /** 可以匹配若干层级路径// 白名单,不需要登录就可以访问String[] urls = {"/admins/login","/doc.html","/**/*.css","/**/*.js","/favicon.ico","/v2/api-docs","/swagger-resources"};http.csrf().disable(); // 禁用防止跨域访问,如果无此配置,白名单路径的异步访问也会出现403错误http.authorizeRequests() // 请求需要被授权才可以访问.antMatchers(urls) // 匹配某些路径.permitAll() // 允许直接访问(不需要经过认证和授权).anyRequest() // 除了以上配置过的其它任何请求.authenticated(); // 已经通过认证,即已经登录过才可以访问// 添加处理JWT的过滤器,必须执行在处理用户名、密码的过滤器(Spring Security内置)之前http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);}}

大致执行流程

完整编码流程:基于Security+JWT的管理员登录

  • 相关依赖:spring-boot-starter-securityjjwt
  • 创建管理员登录的VO类,例如AdminLoginVO
  • AdminMapper接口和AdminMapper.xml文件中实现:根据用户名查询管理员信息,应该至少包括:用户名、密码、权限
  • 自定义类,实现UserDetailsService接口,重写loadUserByUsername()方法,在此类中通过AdminMapper的查询找到对应的管理员信息,并封装到UserDetails类型的对象中返回
  • 创建Security配置类,继承自WebSecurityConfigurerAdapter类,在此类中使用@Bean方法得到AuthenticationManager对象,使用@Bean方法得到BCryptPasswordEncoder对象
  • IAdminService接口中添加登录的抽象方法,并在AdminServiceImpl中重写此方法,在方法体中,调用AuthenticationManagerauthenticate()执行认证,如果认证通过,应该生成JWT数据并返回,此JWT数据中应该包含用户名及必要信息
  • AdminController中处理登录请求,并通过调用IAdminService类型的组件来实现,将调用得到的JWT响应到客户端去

完整编码流程:登录后的访问

  • 在Security的配置类中,指定一些白名单,这些是不需要登录就可以直接访问的,其它请求路径都必须登录后才可以访问,需要注意:登录、注册等请求路径必须在白名单,否则不合理
  • 创建JWT过滤器,在此过滤器中:
    • 清除Security的上下文
    • 从请求头中获取JWT
    • 对JWT数据进行基本判断(是否有值),如果没有有效值,直接放行
    • 如果获取到有效的JWT,则解析,得到用户信息,将用户信息存入到上下文中
  • 在Security的配置类中,添加以上过滤器,将其添加在UsernamePasswordAuthenticationFilter之前

关于CORS

CORS:跨域的异步访问,默认情况下,是不允许的。

在使用Spring MVC框架时,需要允许跨域访问时,可以自定义配置类,实现WebMvcConfigure接口,重写其中的addCorsMappings()方法:

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOriginPatterns("*").allowedHeaders("*").allowedMethods("*").allowCredentials(true).maxAge(3600);}}

当项目中进一步使用了Spring Security框架后,当客户端提交复杂请求(自定义了请求头中非常规属性,例如添加了Authorization属性)时,还需要在Spring Security的配置类允许复杂请求的跨域访问,解决方案可以是:

http.cors();

或者:

http.antMatchers(HttpMethod.OPTIONS, "/**").permitAll();

之所以需要进行这样的处理,是因为复杂请求本身有预检(PreFlight)机制,在提交请求时,客户端会自动先提交OPTIONS类型的请求,此时服务器端可能是不通过的,则会出现403错误,并且,实质尝试提交的请求(例如GETPOST)中复杂请求头部信息不会被提交。

在浏览器端,一旦成功的提交了复杂请求,则后续不会自动提交OPTIONS请求执行预检。

实现授权访问

实现授权访问的步骤:

  • 当用户尝试登录时,应该根据用户名从数据库中查询出此管理员的权限信息

  • UserDetailsServiceImpl中,(当登录认证时,Spring Security框架会自动调用此类中的loadUserByUesrname()方法),根据用户名查询到有效管理员信息后,向UserDetails中存入权限信息

    • List<String>格式的权限集合转换成String...格式即可,例如:

      admin.getPermissions().toArray(new String[] {})
      
  • AdminServiceImpllogin()中,认证成功后,从返回的Authentication中取出权限信息,并其生成到JWT中

    • 为保证后续能从JWT中取出权限且还原成正常的格式,应该将权限列表(Collection<? extend GrandtedAuthority>)转换成JSON格式的字符串再写入
  • JwtAuthorizationFilter中,从JWT中解析出权限,并存入到Security的上下文中

    • 从JWT中解析出的权限是JSON格式的字符串,需还原成Collection<? extend GrandtedAuthority>类型才可以存入到Security的上下文中,可以还原成List<SimpleGrantedAuthority>
  • 在Security的配置类SecurityConfiguration上添加注解@EnableGlobalMethodSecurity(prePostEnabled = true)以开启全局的授权访问检查

    • 此配置是一次性的配置
  • 在控制器中,在处理请求的方法上,使用@PreAuthorize注解,配置其中的hasAuthority属性,即可要求此请求必须具有某种权限

    • 例如:@PreAuthorize("hasAuthority('/ams/admin/read')")

根据用户名查询管理员的权限

首先,在AdminLoginVO中添加必要的属性:

package cn.tedu.csmall.passport.pojo.vo;import lombok.Data;import java.io.Serializable;
import java.util.List;@Data
public class AdminLoginVO implements Serializable {/*** 管理员的id*/private Long id;/*** 用户名*/private String username;/*** 密码(密文)*/private String password;/*** 账号是否启用,0=禁用,1=启用*/private Integer enable;/*** 此账号的权限列表*/private List<String> permissions;}

然后,在AdminMapper.xml中配置查询:

<!-- AdminLoginVO getByUsername(String username); -->
<select id="getByUsername" resultMap="LoginResultMap">SELECTams_admin.id,ams_admin.username,ams_admin.password,ams_admin.enable,ams_permission.valueFROM ams_adminLEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_idLEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_idLEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.idWHEREams_admin.username=#{username}
</select><resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginVO"><id column="id" property="id" /><result column="username" property="username" /><result column="password" property="password" /><result column="enable" property="enable" /><collection property="permissions" ofType="java.lang.String"><constructor><arg column="value" /></constructor></collection>
</resultMap>

关于JWT过滤器的处理细节

解析JWT是可能失败的,例如JWT数据过期、签名错误、数据非法等,这些错误都应该被处理,否则,就会存在异常未处理的情况,最终将导致500错误!

关于以上可能的错误,应该大致分为3类,一类是JWT数据过期,一类是JWT数据被恶意篡改,再另外还有可能是其它的错误。

首先,先在ServiceCode中添加新的业务状态码,对应一些错误:

/*** 错误:JWT数据错误,可能被恶意篡改*/
public static final int ERR_JWT_INVALID = 40001;
/*** 错误:JWT过期*/
public static final int ERR_JWT_EXPIRED = 40300;

然后,需要在JWT过滤器中,自行使用try...catch来捕获并处理异常!

在登录的用户身份标识中添加自定义信息

Spring Security框架中并没有使用、封装用户的ID等相关信息,如果使用过程中,需要自行封装更多的信息,并添加到用户身份标识中,则需要:

  • 自定义类实现UserDetails接口
  • 或,自定义类继承User

并且,在自定义类中添加所需的属性,例如ID,然后,在UserDetailsService的实现类中,在loadUserByUsername()方法返回自定义类的对象。

所以,创建AdminDetails类:

@Setter
@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {private Long id;public AdminDetails(String username, String password, boolean enabled,Collection<? extends GrantedAuthority> authorities) {super(username, password, enabled,true, true, true,authorities);}}

UserDetailsServiceImpl中,需要返回时:

List<String> permissions = admin.getPermissions();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String permission : permissions) {authorities.add(new SimpleGrantedAuthority(permission));
}AdminDetails adminDetails = new AdminDetails(admin.getUsername(),admin.getPassword(),admin.getEnable() == 1,authorities
);
adminDetails.setId(admin.getId());
log.debug("即将向Spring Security返回AdminDetails:{}", adminDetails);

接下来,在AdminServiceImpllogin()方法中,通过AuthenticationManagerauthenticate()执行认证且通过认证的返回结果就是以上AdminDetails对象,所以,可以从中获取管理员的id,并用于生成JWT数据,则用户登录成功后得到的JWT数据中将包含Id信息。

后续,客户端提交请求时,携带的JWT也是包含Id信息的,可以在JwtAuthenticationFilter中解析得到此Id,最终,此Id值应该封装到Security的上下文中,则可以利用UsernamePasswordAuthenticationToken类的principal属性(Object类型),所以,自定义类,用于封装后续可能需要使用到的管理员信息:

package cn.tedu.csmall.passport.security;import lombok.Data;import java.io.Serializable;/*** 当前登录的当事人** @author java@tedu.cn* @version 0.0.1*/
@Data
public class LoginPrincipal implements Serializable {/*** 当前登录的用户id*/private Long id;/*** 当前登录的用户名*/private String username;}

然后,在过滤器,将其存入:

// 准备当前登录用户的当事人信息
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(Long.parseLong(id.toString()));
loginPrincipal.setUsername(username.toString());// 当解析成功后,应该将Authentication存入到Spring Security的上下文中
Authentication authentication= new UsernamePasswordAuthenticationToken(loginPrincipal, null, authorities);
//                                             == 以上封装了当事人信息 ==
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
log.debug("已经向Security的上下文中写入:{}", authentication);

至此,当客户端携带JWT访问服务器端时,服务器端的Security的上下文中就包含了管理员的id、用户名、权限,其中,权限不需要自行使用,都是Security框架自动判断(你只需要在控制器处理请求的方法上配置@PreAuthorize注解即可),当需要获取管理员的id、用户名时,可以在控制器处理请求的方法的参数列表中添加Authentication即可,此参数就是Security上下文中的认证信息(过滤器中存入的对象),例如:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 401)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult list(Authentication authentication) {log.debug("接收到查询管理员列表的请求");log.debug("当前认证信息:{}", authentication);LoginPrincipal principal = (LoginPrincipal) authentication.getPrincipal();Long id = principal.getId();log.debug("从认证信息中获取当前登录的管理员的id:{}", id);String username = principal.getUsername();log.debug("从认证信息中获取当前登录的管理员的用户名:{}", username);List<AdminListItemVO> admins = adminService.list();return JsonResult.ok(admins);
}

Authentication中获取LoginPrincipal比较麻烦,还需要自行获取、转换类型,可以改为声明LoginPricipal参数(在过滤器中封装到UsernamePasswordAuthenticationTokenpricipal属性中的对象),然后,在此参数前添加@AuthenticationPrincipal注解,即可直接使用:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 401)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult list(@AuthenticationPrincipal LoginPrincipal loginPrincipal) {log.debug("接收到查询管理员列表的请求");log.debug("当前认证信息中的当事人信息:{}", loginPrincipal);Long id = loginPrincipal.getId();log.debug("从认证信息中获取当前登录的管理员的id:{}", id);String username = loginPrincipal.getUsername();log.debug("从认证信息中获取当前登录的管理员的用户名:{}", username);List<AdminListItemVO> admins = adminService.list();return JsonResult.ok(admins);
}

Spring Security框架的相关概念

Authentication

认证信息,在项目中,它是一个接口,常用的实现类是UsernamePasswordAuthenticationToken,根据应用场景不同,其表现的意义也不同,例如,在AuthenticationManagerauthenticat()方法的参数中,它主要用于封装用户名和密码,但是,在此方法的返回结果中,它表示成功登录的用户信息。

Authorization

认证,在项目中,它主要表现为携带JWT的请求头的属性名,是建议使用的属性名。

Authority

权限,通常表现为一些字符串,这些字符串应该具有唯一、易于阅读的特性,框架会根据登录后的用户信息和控制器中配置的权限进行检查,以判断某用户是否具有执行此操作的权力。

Principal

当事人,是Authentication中的部分属性,以UsernamePasswordAuthenticationToken为例,它当中就包括了Principal、Credentials、Authorities这3大部分,在项目中,如果Authentication是用于执行认证,则此Principal就是用户名,如果Authentication是用户认证后的信息,则可以包含其它意义,例如ID、用户名等。

Token

票据、令牌,指的是携带了一部分有意义的数据的信息。

UserDetails

用户详情,是用于执行认证过程中,封装用户的信息,例如,在UserDetailsService接口的实现类中,在loadUserByUsername()方法中就应该返回此类型的对象,则Spring Security会自动调用此方法来获取UserDetails类型的结果,此结果中应该包含密码,且Spring Security会自动调用PasswordEncoder来验证用户请求登录时输入的密码,并且,此类型也会是认证成功后Authentication中的Principal。

使用Spring Security框架时涉及的文件

pom.xml

需要添加相关依赖,当需要使用Spring Security时,添加spring-boot-starter-security,当需要使用JWT时,添加jjwt(生成和解析JWT数据的工具包)和fastjson(实现对象与JSON字符串互相转换的工具包)。

UserDetailsServiceImpl

UserDetailsService接口的实现类,需要重写其中的UserDetails loadUserByUsername(String s)方法,Spring Security在执行认证时会自动调用此方法,此方法的返回结果必须至少包括:密码、权限和其它必要的信息(根据API决定)。

关于返回的UserDetails,通常可能使用User类型,但是,此类型并不包含id等属性,所以,也可能自定义类实现UserDetails接口,或自定义类继承自User,然后作为返回的UserDetails对象。

SecurityConfiguration

是Spring Security框架的配置类,需要继承自WebSecurityConfigurerAdapter

此类可以添加@EnableGlabalMethodSecurity(prePostEnabled = true)注解,用于开启全局的方法上的授权检查(允许在处理请求的方法使用@PreAuthorize检查权限)。

通常,在此类中会配置PasswordEncoder对应的@Bean方法(此方法也可以在其它配置类中),在执行认证时,Spring Security会自动使用此PasswordEncoder对象的matches()方法来验证密码。

  • 如果密文是BCrypt算法生成的,则应该在@Bean方法中返回BCryptPasswordEncoder,如果没有密文(密码并未加密),则此方法中应该返回NoOpPasswordEncoder,以此类推

在此类中,还可能配置AuthenticationManager对应的@Bean,此方法一般是重写的方法,用于返回AuthenticationManager对象,用于在其它组件中执行认证,例如在Service中自动装配此类型的属性,并调用authenticate()方法来执行认证。

在此类中,比较重要的是重写void configure(HttpSecurity http)方法,在此方法内部对如何处理请求进行配置,通常,需要配置的有:

  • http.csrf().disable():禁用防止跨域伪造的攻击,是固定的配置
  • http.cors():在Spring Security的过滤器链中添加CorsFilter,以实现放行复杂的异步请求的预检

另外,还应该调用http参数对象及对应的链式方法进行一些配置:

  • authroizeRequests():对请求进行认证
  • antMatchers():匹配某些路径,此方法并不决定这些路径应该如何被处理
  • permitAll():允许此前的antMatchers()配置的路径的所有方法直接访问
  • anyRequest():匹配其它的任何请求(请求路径),即在此前调用的所有antMatchers()以外的请求,此方法也不决定这些请求应该如何被处理
  • authenticated():已经认证的

AdminDetails

UserDetails接口的实现类,或User的子类,这个类的主要作用是对User类进行扩展,因为在开发实践中,需要的认证信息中通常还包括用户的id等信息,而Spring Security的User中并没有定义这些属性,所以,不满足开发需求,则其进行扩展。

当编写UserDetailsServiceImplloadUserByUsername()时,此方法应该返回AdminDetails类型的对象。

当调用AuthenticationManagerauthenticate()方法时,返回结果中的Principal就是AdminDetails对象。

JwtUtils

主要定义生成JWT和解析JWT的方法,便于在其它组件中直接调用,而不必关心生成JWT和解析JWT的细节。

JwtAuthorizationFilter

这是处理JWT的过滤器,其主要作用是对客户端的请求头中的有效JWT进行解析,并将解析得到的结果封装到认证信息中,然后将认证信息到Spring Security的上下文中,以至于:

  • Spring Security会自动从上下文中取出认证信息中的权限部分,用于自动判断权限,所以,在控制器中处理请求的方法上,只需要使用@PreAuthroize注解即可实现授权访问的检查
  • 在控制器中处理请求的方法的参数列表中,可以添加Authentication参数,则在控制器中就可以获取认证信息,甚至,不使用Authentication参数,而是使用自定义的当事人类型,添加@AuthenticationPrincipal注解,就可以直接得到自定义的当事人信息

在此过滤器的实现过程中,需要注意:

  • 对于明显无效的JWT(为null、是空字符串等)应该直接放行,因为有些请求本不应该携带JWT数据,例如登录、注册……
  • 解析JWT是可能失败的,特别是JWT可能过期,则应该直接对相关的异常进行处理,当前组件是过滤器,是执行在所有其它组件之前的,所以,不能抛出异常使用Spring MVC统一处理异常的机制
  • 从JWT中解析出相关数据后,应该封装到UsernamePasswordAuthenticationToken中,其中,权限信息应该封装到此类型的authorities属性中,用户的登录信息(当事人信息)应该封装到此类型的principal属性中,另外,如果某个其它的系统(其它项目)并没有权限相关的概念,此处的authorities也不能为空,否则,会被Spring Security视为“没有有效的认证信息”
  • 一定要将认证信息存入到Spring Security的上下文中
  • 为了避免后续使用中可能出现的某些问题(例如第1次访问携带JWT最终向上下文中存入信息,后续不再携带JWT也会视为已登录),应该在过滤器刚刚执行时,清除Spring Security的上下文中的信息

LoginPrincipal

主要用于封装当事人的多个属性,例如同时将id、 用户名存入到UsernamePasswordAuthenticationToken中去,后续,在控制器中处理请求的方法的参数列表中,就可以使用@AuthenticationPrincipal LoginPrincipal loginPrincipal参数来得到当前登录的当事人信息。

其它相关类或实现:AdminMapper及相关

必须实现“根据用户名查询管理员信息”的功能,且返回的结果中必须包含此管理员的权限列表。

其它相关类或实现:AdminServiceImpl

在处理登录的过程中,应该调用AuthenticationManagerauthenticate()执行认证,并获取返回结果,然后,将返回结果中的必要数据用于生成JWT,作为业务方法的返回值。

其它相关类或实现:AdminController

在处理登录时,必须响应调用Service组件时返回的JWT数据。

在其它需要获取认证信息的方法中,在参数列表中添加@AuthenticationPrincipal LoginPrincipal loginPrincipal来获取当前登录的当事人信息。

当某个请求必须拥有某种权限才可以访问时,在方法上添加@PreAuthorize注解配置权限。

作业1:实现csmall-server中的认证访问

目标:原csmall-server项目中的所有功能都是必须登录以后才可以访问的。

开发步骤:

  • csmall-server项目中添加相关依赖项

  • 在项目的启动类的@SpringBootApplication注解中排除UserDetailsServiceAutoConfiguration

    • @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
      
    • 不再使用临时的user用户名和随机密码

  • 【手写】JWT工具类

  • 创建LoginPrincipal

  • 【手写】使用JwtAuthenticationFilter获取、解析JWT,并将当事人信息封装到Authentication中,并存入到Spring Security的上下文中

  • 创建SecurityConfigration

    • 需要http.cors()
    • 需要http.csrf().disable()
    • 所有请求都必须登录才可以访问

注意:原有的所有客户端请求都必须携带JWT。

作业2:实现csmall-server中的其它功能

检查并确保完成以下功能:

  • 增加品牌
  • 查询品牌列表
  • 根据id删除品牌
  • 增加类别
  • 查询类别列表
  • 根据id删除类别
  • 增加属性
  • 查询属性列表
  • 根据id删除属性
  • 增加相册
  • 查询相册列表
  • 根据id删除相册
  • 增加属性模版
  • 查询属性模版列表
  • 根据id删除属性模版

以上功能需完成持久层、业务逻辑层、控制器、界面。

Spring Security 框架详解相关推荐

  1. 【Spring Security】Spring Security框架详解

    文章目录 前言 一.框架概述 Spring Security的架构 Spring Security的主要特点 二.认证 HTTP Basic认证 表单登录 OpenID Connect 三.授权 基于 ...

  2. Spring Security应用详解(集成SpringBoot)

    Spring Security应用详解 集成SpringBoot 工作原理 认证流程 授权流程 集成SpringBoot 1.Spring Boot介绍 Spring Boot是一套Spring的快速 ...

  3. Spring Security 应用详解 集成SpringBoot —— 简单入门

    spring 容器配置 SpringBoot工程启动会自动扫描启动类所在包下的所有Bean,加载到spring容器. 1)Spring Boot配置文件 在resources下添加applicatio ...

  4. spring security配置详解

    1.<intercept-url pattern="/login" access="IS_AUTHENTICATED_ANONYMOUSLY"/> ...

  5. SpringSecurity权限管理框架系列(六)-Spring Security框架自定义配置类详解(二)之authorizeRequests配置详解

    1.预置演示环境 这个演示环境继续沿用 SpringSecurit权限管理框架系列(五)-Spring Security框架自定义配置类详解(一)之formLogin配置详解的环境. 2.自定义配置类 ...

  6. Spring和Security整合详解

    Spring和Security整合详解 一.官方主页 Spring Security 二.概述 Spring 是一个非常流行和成功的 Java 应用开发框架.Spring Security 基于 Sp ...

  7. shiro放行_Shiro框架详解 tagline

    部分面试资料链接:https://pan.baidu.com/s/1qDb2YoCopCHoQXH15jiLhA 密码:jsam 想获得全部面试必看资料,关注公众号,大家可以在公众号后台回复" ...

  8. spring2.0和spring2.5及以上版本的jar包区别 spring jar 包详解

    spring jar 包详解 spring.jar是包含有完整发布的单个jar包,spring.jar中包含除了 spring-mock.jar里所包含的内容外其它所有jar包的内容,因为只有在开发环 ...

  9. 一文搞定 Spring Data Redis 详解及实战

    转载自  一文搞定 Spring Data Redis 详解及实战 SDR - Spring Data Redis的简称. Spring Data Redis提供了从Spring应用程序轻松配置和访问 ...

最新文章

  1. Djang1.8+Python2.0迁移到Django2.0+Python3.6注意事项(转)
  2. 5G+XR:让视频增强技术在工业领域大有所为
  3. 误差、方差、偏差、噪声、训练误差+验证误差、偏差方差窘境、错误率和误差、过拟合与欠拟合
  4. 一个在windowsXP启动linux as(grub引导)的简单方法
  5. js高级---js运行原理
  6. 使用js实现时钟效果
  7. 【Linux】一步一步学Linux——whereis命令(15)
  8. php5.2 $_GET漏洞,ThinkPHP5 远程代码执行漏洞-动态分析
  9. openlayers小车_openlayer 车辆跟着轨迹移动
  10. IOS UIColor 自定义颜色
  11. JDK使用最多的模式之一--观察者模式
  12. pn532跳坑小指南
  13. java 对日期的加减运算
  14. 没有币如何复制百度文库中的文章
  15. 自动化脚本任务计划程序(Windows)
  16. FlashFXP中文破解解压版
  17. 模型汇总-14 多任务学习-Multitask Learning概述
  18. 网络编程基础(Socket ServerSocket)
  19. [转]基于大规模语料的新词发现算法
  20. Mac ERROR launching JD-GUI

热门文章

  1. 在医疗行业中,FPGA扮演什么角色?
  2. 以Base64字符串形式上传文件——服务器端Java/Servlet接受请求数据中的字符串内容并转换成文件
  3. PostgreSQL索引膨胀
  4. 学习笔记|PSO粒子群算法(1)
  5. Angr 从入门到放弃(二)
  6. 他曾与20人挤在80㎡的宿舍 现在拥有全国最大的自媒体平台
  7. Web Service漏洞挖掘
  8. 为李化奎老师画《沙枣树》题
  9. 改进Bootstrap中的响应式侧边栏
  10. 【转载】自抗扰控制器的学习过程