一、常见的认证机制
1.1 Http Basic Auth
Http Basic Auth 是每次请求API时都提供用户的username和password,简言之, Basic Auth 是配合Restful API使用的最简单的认证方式,只需要提供用户名和密码即可。但由于有把用户名和密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的Restful API时,应尽量避免采用 Http Basic Auth认证方式。
1.2 Cookie Auth
Cookie认证机制是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象,通过客户端携带的Cookie对象与服务器端的Session对象匹配来实现状态管理。默认情况下,关闭浏览器时Cookie会被删除,可以通过设置Cookie的超时时间使Cookie在一定时间内有效。
1.3 OAuth第三方授权
OAuth(开放授权,Open Authorization)是一个开放的授权标准,为用户资源的授权提供了一个安全、开放而又简易的标准。与以往的授权方式不同之处是OAuth的授权不会使第三方触及到用户的账号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAuth是安全的。
OAuth 在 “客户端” 与 “服务提供商” 之间,设置了一个授权层(authorization layer)。“客户端” 不能直接登录 “服务提供商”,只能登录授权层,以此将用户与客户端区分开来。“客户端” 登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。“客户端” 登录授权层以后,“服务提供商” 根据令牌的权限范围和有效期,向 “客户端” 开放用户储存的资料。
这种基于OAuth的认证机制是用于个人消费类的互联网产品,如社交类、商超类App等应用,但不太适合拥有自有认证权限管理的企业应用
1.4 Token Auth
使用基于Token的身份验证方法,在服务端不需要存储用户的登陆信息。流程如下:
- 客户端使用用户名和密码请求登陆。
- 服务端收到请求,去验证用户名和密码。
- 验证成功后,服务端会签发一个Token,再把这个Token发送给客户端。
- 客户端收到Token以后可以把它存储在Cookie本地。
- 客户端每次向服务端请求资源时需要携带Cookie中该Token。
- 服务端收到请求后,验证客户端携带的Token,如果验证成功则返回数据。
Token认证方式比Http Basic Auth安全,比Cookie Auth更节约服务器资源,比OAuth更加轻量。Token Auth具体有以下优点:
- 支持跨域访问:Cookie是不允许跨域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过Http头传输
- 无状态(服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token自身包含了登陆用户的部分信息,只需要在客户端的cookie或本地介质存储状态信息。
- 更适用CDN:可以通过内容分发网络请求服务端的所有资料(如js,html,图片等),而服务端只需要提供API即可。
- 去耦合:不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在API被调用时,生成Token即可。
- 更适用于移动应用:当移动设备不支持Cookie验证时,采用Token验证即可。
- CSRF:因为不再依赖于Cookie,就不需要考虑对CSRF(跨站请求伪造)的防范。
- 性能:一次网络往返时间(通过数据库查询Session信息)总比做一次SHA256计算的Token验证和解析要费事的多。
- 基于标准化:创建的API可以采用标准话的 JSON Web Token(JWT)。这个标准已经存在多个后端库( .net,Ruby,Java,Python,Php )和多家公司的支持(Firebase,Google,Microsoft)。
二、JWT简介
2.1 什么是JWT
JSON Web Token (JWT)是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对进行签名,防止被篡改。
JWT官网: https://jwt.io
JWT令牌的优点:
- JWT基于json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
- 资源服务使用JWT可不依赖认证服务即完成授权。
JWT令牌的缺点:
- JWT令牌较长,占存储空间比较大。
2.2 JWT组成
一个JWT实际上就一个字符串,它由三部分组成,头部、负载与签名。
1)头部(Header)
头部用于描述关于该JWT的最基本信息,例如其类型(即JWT)以及签名所用的算法(如HMAC SHA256 或 RSA)等。这也可以被表示成一个JSON对象。
{
"alg":"HS256",
"typ":"JWT"
}
- alg:签名算法
- typ:类型
对头部的json字符串进行BASE64编码
Base64是一种基于64个可打印字符串来表示二进制数据的表示方式。JDK提供了非常方便的Base64Encoder和Base64Decoder,用它们可以非常方便的完成基于Base64的编码和解码。
2)负载/载荷(Payload)
负载,token中存放有效信息的部分,比如用户名,用户角色,过期时间等,但是不要放密码,会泄露!
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
对头部的json字符串进行BASE64编码
3)签证、签名(Signature)
jwt的第三部分是一个签证信息,由三部分组成:
- Header(Base64编码后)
- Payload(Base64编码后)
- Secret(盐,必须保密)
将头部与载荷分别采用 base64编码后,用“.”相连,再加入盐,最后使用头部声明的编码类型进行加密,就得到了签名。签名=HS256(xxxxxx.yyyyyyy,salt)
JWT官方token生成工具
注意:secret是保存在服务器端的,jwt在签发生成也是在服务器端的,secret就是用来进行jwt的签发和验证,所以,它就是服务器端的私钥,在任何场景都不应该泄漏。一旦客户端得知这个secret,那就意味着客户端是可以自我签发jwt了。
三、JWT的使用
3.1 导入依赖
<!--jwt依赖-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
3.2 测试JWT生成和校验Token
@Test
public void testJwt() throws InterruptedException {
//生成JWT令牌
//创建Builder对象
JWTCreator.Builder builder = JWT.create();
//设置token生成的时间
builder.withIssuedAt(new Date());
//设置用户的角色权限信息。参数信息可以是map集合
builder.withClaim("username","zhangsan");
builder.withClaim("role","ROLE_user,ROLE_manager");
//设置token的有效时间
builder.withExpiresAt(new Date(System.currentTimeMillis()+5000));
//生成token
String token = builder.sign(Algorithm.HMAC256("qfjava"));
System.out.println(token);
//模拟token过期(休眠6000)
Thread.sleep(6000);
//出现token过期异常TokenExpiredException
//校验token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("qfjava")).build();
DecodedJWT verify = jwtVerifier.verify(token);
//获取token令牌中的信息
System.out.println(verify.getClaim("username").asString());
System.out.println(verify.getClaim("role").asString());
}
四、RSA非对称加密
4.1 为什么使用RSA对称成加密
从JWT生成的token组成上来看,要想避免token被伪造,主要就得看签名部分了,而签名部分又有三部分组成,其中头部和载荷的base64编码,几乎是透明的,毫无安全性可言,那么最终守护token安全的重担就落在了加入的盐上面了!
如果salt泄漏就会导致token伪造,最终导致系统安全隐患
这时,我们就需要对盐采用非对称加密的方式进行加密,以达到生成token与校验token方所用的盐不一致的安全效果!
RSA非对称加密:
- 加解密:
- 公钥加密 私钥解密
- 加验签
- 私钥加签 公钥验签
4.2 生成公钥、私钥秘钥对
用于生成公钥私钥的工具类
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* @param filename 公钥保存路径,相对于classpath
* @return 公钥对象
* @throws Exception
*/
public static PublicKey getPublicKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPublicKey(bytes);
}
/**
* 从文件中读取密钥
*
* @param filename 私钥保存路径,相对于classpath
* @return 私钥对象
* @throws Exception
*/
public static PrivateKey getPrivateKey(String filename) throws Exception {
byte[] bytes = readFile(filename);
return getPrivateKey(bytes);
}
/**
* 获取公钥
* @param bytes 公钥的字节形式
* @return
* @throws Exception
*/
public static PublicKey getPublicKey(byte[] bytes) throws Exception {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 获取密钥
* @param bytes 私钥的字节形式
* @return
* @throws Exception
*/
public static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException,
InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 根据密文,生存rsa公钥和私钥,并写入指定文件
*
* @param publicKeyFilename 公钥文件路径
* @param privateKeyFilename 私钥文件路径
* @param secret 生成密钥的密文
*/
public static void generateKey(String publicKeyFilename, String privateKeyFilename, String
secret, int keySize) throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
keyPairGenerator.initialize(Math.max(keySize, DEFAULT_KEY_SIZE), secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
// 获取公钥并写出
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFilename, publicKeyBytes);
// 获取私钥并写出
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFilename, privateKeyBytes);
}
private static byte[] readFile(String fileName) throws Exception {
return Files.readAllBytes(new File(fileName).toPath());
}
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists()) {
dest.createNewFile();
}
Files.write(dest.toPath(), bytes);
}
}
测试生成公钥和私钥
@Test
public void testPublicKeyAndPrivateKey() throws Exception {
//使用工具类生成公钥和私钥(确保路径存在)
String privateKeyFileName = "D://workspace//key//rsa.pri";
String publicKeyFileName = "D://workspace//key//rsa.pub";
RsaUtils.generateKey(publicKeyFileName,privateKeyFileName,"qfjava",2048);
}
4.3 测试jwt令牌的生成与校验
导入JJTW依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
</dependency>
JSONutils工具类
public class JsonUtils {
public static final ObjectMapper mapper = new ObjectMapper();
/**
* 将对象转换成json串
* @param obj
* @return
*/
public static String toString(Object obj) {
if (obj == null) {
return null;
}
if (obj.getClass() == String.class) {
return (String) obj;
}
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
return null;
}
}
/**
* 将json串转换成对象
* @param json
* @param tClass
* @param <T>
* @return
*/
public static <T> T toBean(String json, Class<T> tClass) {
try {
return mapper.readValue(json, tClass);
} catch (IOException e) {
return null;
}
}
}
JwtUtils工具类。生成与校验token
public class JwtUtils {
private static final String JWT_PAYLOAD_USER_KEY = "user";
/**
* 私钥加密token
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位分钟
* @return JWT
*/
public static String generateTokenExpireInMinutes(Object userInfo, PrivateKey privateKey, int expire) {
//计算过期时间
Calendar c = Calendar.getInstance();
c.add(Calendar.MINUTE,expire);
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes())))
.setExpiration(c.getTime())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 私钥加密token
* @param userInfo 载荷中的数据
* @param privateKey 私钥
* @param expire 过期时间,单位秒
* @return JWT
*/
public static String generateTokenExpireInSeconds(Object userInfo, PrivateKey privateKey, int expire) {
//计算过期时间
Calendar c = Calendar.getInstance();
c.add(Calendar.SECOND,expire);
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY, JsonUtils.toString(userInfo))
.setId(new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes())))
.setExpiration(c.getTime())
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
/**
* 获取token中的用户信息
*
* @param token 用户请求中的令牌
* @param publicKey 公钥
* @return 用户信息
*/
public static Object getInfoFromToken(String token, PublicKey publicKey, Class userType) {
//解析token
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
Claims body = claimsJws.getBody();
String userInfoJson = body.get(JWT_PAYLOAD_USER_KEY).toString();
return JsonUtils.toBean(userInfoJson, userType);
}
}
测试私钥加密,公钥解密
@Test
public void testValidatePublicKeyAndPrivateKey() throws Exception {
//私钥加密,公钥解密
//获取私钥文件路径
String privateFile = ResourceUtils.getFile("classpath:rsa.pri").getPath();
//获取私钥对象
PrivateKey privateKey = RsaUtils.getPrivateKey(privateFile);
//生成Token
HashMap map = new HashMap();
map.put("username","zhangsan");
map.put("role","ROLE_admin,ROLE_user");
String token = JwtUtils.generateTokenExpireInSeconds(map, privateKey, 10);
//获取公钥文件路径
String publicFile = ResourceUtils.getFile("classpath:rsa.pub").getPath();
//获取公钥对象
PublicKey publicKey = RsaUtils.getPublicKey(publicFile);
Map infoFromToken = (Map) JwtUtils.getInfoFromToken(token, publicKey, Map.class);
System.out.println(infoFromToken.get("username"));
System.out.println(infoFromToken.get("role"));
}
五、JWT实现前后端分离登录
5.1 前端登录
handleLogin() {
//发送异步请求进行登录
login(this.loginForm).then(res => {
if(res.code == 0){//登录成功
//将token保存到localStorage中
localStorage.setItem("token",res.data);
//通过跳转到首页
this.$router.push({ path: '/' })
}else{
//提示信息
this.$message.error('用户名或密码不正确');
}
})
}
5.2 后端颁发Token
登录成功在service中颁发Token
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public ResultVO login(TbUser user) {
if(user == null || user.getUsername() == null)
return new ResultVO(201,"用户不存在");
TbUser tbUser = userMapper.selectUserByUsername(user.getUsername());
if(tbUser == null)
return new ResultVO(201,"用户名不存在");
//密码校验
String rPassword = DigestUtils.md5DigestAsHex(user.getPassword().getBytes());
if(!rPassword.equals(tbUser.getPassword()))
return new ResultVO(201,"用户密码不正确");
//如果密码正确,则表示登录成功。即颁发令牌
//尽量不要将密码设置到token信息中
tbUser.setPassword("");
try {
//获取私钥
PrivateKey privateKey =
RsaUtils.getPrivateKey(
ResourceUtils.getFile("classpath:key/rsa.pri").getPath());
//生成token
String token = JwtUtils.generateTokenExpireInMinutes(tbUser, privateKey, 30);
return new ResultVO(0,"success",token);
} catch (Exception e) {
e.printStackTrace();
}
return new ResultVO(201,"token生成错误");
}
}
5.3 后端登录拦截
5.3.1 前端解决
每次请求都在请求头中携带token
// request interceptor axios请求拦截器
service.interceptors.request.use(
config => {
config.headers.token = sessionStorage.getItem("token");
return config
}
)
如果后端响应结果为error则清除sessionStorage,并跳转到登录页面
import router from '../router'
// response interceptor axios响应拦截器
service.interceptors.response.use(
response => {
if(response.data.code == 403){
sessionStorage.removeItem("token")
router.push('/login1')
}
return response.data
}
)
5.3.2 后端解决
后端定义拦截器,拦截所有的请求,如果发现token失效则返回error
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
//放行请求方法为OPTIONS的请求
String method = request.getMethod();
if(method.equals("OPTIONS")){
return true;
}
//获取请求头中的请求信息
String token = request.getHeader("token");
//使用公钥解密进行校验
//获取公钥
PublicKey publicKey = RsaUtils.getPublicKey(
ResourceUtils.getFile("classpath:key/rsa.pub").getPath());
try {
TbUser tbUser = (TbUser) JwtUtils.getInfoFromToken(token, publicKey, TbUser.class);
return true;
}catch (Exception e){
e.printStackTrace();
}
//失败要响应json数据给前端
response.sendRedirect(request.getContextPath()+"/result");
return false;
}
}
@RestController
@RequestMapping("user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("login")
public ResultVO login(@RequestBody TbUser tbUser){
return userService.login(tbUser);
}
@RequestMapping("result")
public ResultVO result(){
return new ResultVO(201,"error");
}
}
配置拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
//拦截所有请求,放行无须登录的请求
.addPathPatterns("/**")
.excludePathPatterns("/user/login","/user/result");
}
5.4 前端登录拦截
前端使用路由守卫进行登录拦截,减少服务器端压力
在路由配置中加入路由守卫
/**
* to: 到哪去
* from:从哪儿来的
* next:放行
* to.path :获取要路由的路径
*/
const whiteList = ['/login'] // 定义白名单
router.beforeEach(async(to, from, next) => {
// 从cookie中获取token(key:token)
const hasToken = localStorage.getItem("token");
if (hasToken) {//已登录
if (to.path === '/login') {
// 如果已经登录,且访问login页面,则直接跳转至首页
next({ path: '/' })
} else {
next() //如果已经登录,则直接放行
}
} else {//未登录
if (whiteList.indexOf(to.path) !== -1) {
//如果没有登录,且出现在白名单,则直接放行
next()
} else {
//如果没有登录,跳转至登录页面
next(`/login`)
}
}
})