一、常见认证机制
1、传统session
我们知道,http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
但是这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来.
基于session认证所显露的问题
Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
2、基于JWT的token
基于JWT的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息(即不需要在 redis 或 mysql 中存储登录信息)。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
流程上是这样的:
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过 jwt 结构生成带有用户信息的 token,并发送给用户
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)
策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *
。
该方法简单粗暴但是缺点也很明显,token 一旦下发便不受服务端控制,如果发生token泄露,服务器也只能任其蹂躏
3、基于Redis+token
该种方式的 token 不存储任何用户信息,好处是随时可以删除某个token,阻断该token继续使用,同时因为redis 可作为数据中心,从而轻易实现单点登录
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器生成随机数 token(不存储任何信息,只是标志),并存储 token 及对应用户的信息
- 服务器将token返回给用户,客户端存储token,并在每次请求时附送上这个token值
- 访问时,服务器从redis中获取token及对应用户信息,并维持其过期时间
4、区别
传统 session 、cookie 存在问题
- 当用户数量较大时,极大的占用服务端的资源
- 多服务时需要进行 session 共享,较为繁琐
整体是不推荐使用
基于JWT的token
- 优点:无状态,即不在服务端存储认证(会话)信息,节省了资源
- 优点:天生支持单点登录
- 优点:简单粗暴
- 缺点:token 一旦下发便不受服务端控制
- 缺点:难以为 token 续期
基于Redis+token
- 优点:使用 redis 作为数据中心,不占用服务端资源,同时支持单点登录
- 优点:token 续期简单
5、方案选择
如果项目并不大,JWT是个好选择,天然的去中心化模式,会话状态由令牌本身自解释,简单粗暴
但是缺点页很明显,如题所示,一旦下发便不受服务端控制,如果发生token泄露,服务器也只能任其蹂躏,在其未过期期间不能有任何措施
常见的做法是再从JWT上封装一层,提供一个类似黑名单的机制,每次访问系统时先检查此JWT令牌是否已经被拉黑,此模式虽然暂时解决了问题,但是此时你会发现,项目架构又回到了传统Session模型中,对JWT来讲属于自断招牌
这时候又凸显出了传统Session模型的好处,服务器生成令牌下发给前端,前端只持有令牌本身,而令牌代表的数据则完全由服务器说了算,此种模式下:什么Session会话、踢人下线之类的都是信手拈来,但是缺点又回来了:失去了去中心化,水平扩展比较难,且服务器需要维护所有用户的数据状态,性能压力比较大
常见解决方案也很简单,那就是把会话数据放到专业的数据中心,比如Redis, 此模式既保证了服务器对会话数据的可控性,又保证了服务水平扩展时的会话一致性
二、JWT介绍
1、简介
JSON Web Token
(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。
特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
下列场景中使用JSON Web Token是很有用的:
Authorization (授权)
: 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用。Information Exchange (信息交换)
: 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWT可以被签名,例如,用公钥/私钥对,你可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,您还可以验证内容没有被篡改。
2、JWT 结构
JWT是由三段信息构成的,将这三段信息文本用.
链接一起就构成了Jwt字符串。如下
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).
header
jwt的头部承载两部分信息:
- 声明类型,这里是jwt
- 声明加密的算法 通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
playload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明 (建议但不强制使用) :
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
定义一个payload:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后将其进行base64加密,得到Jwt的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
signature
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret(秘钥)
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。该算法的加密是不可解密的。这样服务端在解析时,可再次执行一次加密算法,比较第三部分是否相同即可,而其他不知道秘钥的情况下无法篡改信息。
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
将这三部分用.
连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
1、base64(header)
2、base64(payload)
3、haeder中的加密算法(base64(header) + base64(payload) + 私钥)
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
3、工作流程
一般是在请求头里加入Authorization
,并加上Bearer
标注:
fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})
服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:
4、总结
优点
- 因为json的通用性,所以JWT是可以进行跨语言支持的,像JAVA,JavaScript,NodeJS,PHP等很多语言都可以使用。
- 因为有了payload部分,所以JWT可以在自身存储一些其他业务逻辑所必要的非敏感信息。
- 便于传输,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的。
- 它不需要在服务端保存会话信息, 所以它易于应用的扩展
安全相关
- 不应该在jwt的payload部分存放敏感信息,因为该部分是客户端可解密的部分。
- 保护好secret私钥,该私钥非常重要。
- 如果可以,请使用https协议
- JWT不是 session ,勿将token当session
缺点
- 无法作废已颁布的令牌,因为所有的认证信息都在JWT中,由于在服务端没有状态,即使你知道了某个JWT被盗取了,你也没有办法将其作废。
- 在JWT过期之前(你绝对应该设置过期时间),你无能为力。
- 类似缓存,由于无法作废已颁布的令牌,在其过期前,你只能忍受”过期”的数据(自己放出去的token,含着泪也要用到底)。
二、Java使用JWT
1、依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
2、生成token
Calendar instance = Calendar.getInstance();
instance.add(Calendar.MINUTE,120); //过期时间120分钟
HashMap<String, Object> header = new HashMap<>();
// 生成令牌
String token = JWT.create()
//.withHeader(header) //设置头,声明加密类型和加密算法,可省略(有默认值)
.withClaim("userName", "rewind") //设置payload荷载信息
.withClaim("userId", 123456) //设置payload荷载信息
.withExpiresAt(instance.getTime()) //设置过期时间
.sign(Algorithm.HMAC256("签名秘钥")); //设置签名/盐salt,保密、复杂
3、验证token
// 创建验证对象
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("签名秘钥")).build();
DecodedJWT decodedJWT = jwtVerifier.verify(token);
Integer userId = decodedJWT.getClaim("userId").asInt(); //获取指定荷载信息
String userName = decodedJWT.getClaim("userName").asString();
Date expiresAt = decodedJWT.getExpiresAt(); //获取过期时间
String header = decodedJWT.getHeader(); //获取header
String payload = decodedJWT.getPayload();
String signature = decodedJWT.getSignature(); //获取加密后的签名
String jwtToken = decodedJWT.getToken(); // 获取token
常见验证失败抛出的异常
// 签名不一致异常
SignatureVerificationException
// token过期异常
TokenExpiredException
// 算法不匹配异常
AlgorithmMismatchException
// 失效的payload异常
InvalidClaimException
4、封装工具类
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
/**
* 秘钥:需要妥善保存,一旦泄露token有可能被破解,实际使用不应该写死
*/
private static final String SECRET = "#$@!FX()!JF%JS$KLS*KFH##%#QPCIXMSJ";
/**
* 签名算法:推荐使用HMAC256
*/
private static final Algorithm ALGORITHM = Algorithm.HMAC256(SECRET);
/**
* 设置默认过期时间 1000*60*60=3600000=1h
*/
private static final Long EXPIRE_DATE = 3600000L;
/**
* 获取token信息
*
* @param claimMap 自定义的条件
* @return token令牌
*/
public static String getToken(Map<String, String> claimMap) {
return getToken(claimMap, EXPIRE_DATE);
}
/**
* @param claimMap 自定义条件
* @param expire 过期时间 单位:毫秒
* @return token令牌
*/
public static String getToken(Map<String, String> claimMap, Long expire) {
JWTCreator.Builder builder = JWT.create();
claimMap.forEach(builder::withClaim);
builder.withExpiresAt(new Date(System.currentTimeMillis() + expire));
return builder.sign(ALGORITHM);
}
/**
* 获取DecodedJWT
*
* @param token token令牌
* @return DecodedJWT
*/
public static DecodedJWT getDecodedJWT(String token) {
return JWT.require(ALGORITHM).build().verify(token);
}
/**
* 获取DecodedJWT
*
* @param algorithm 指定算法
* @param token token令牌
* @return DecodedJWT
*/
public static DecodedJWT getDecodedJWT(Algorithm algorithm, String token) {
return JWT.require(algorithm).build().verify(token);
}
}
5、工具类使用
HashMap<String, String> map = new HashMap<>();
map.put("userId","123");
map.put("userName","rewind");
// 生成token,指定荷载信息和过期时间
String token = JwtUtil.getToken(map, 10000000L);
// 解析验证token
String userId = JwtUtil.getDecodedJWT(token).getClaim("userId").asString();
String userName = JwtUtil.getDecodedJWT(token).getClaim("userName").asString();
三、jwt实现登录
0、实现原理
单体应用中使用拦截器,分布式项目中应在网关配置
(1)支持多机登录
- A 机器登录用户user,后台生成
token
返回前端。 - 当 A 机器再次请求时,带上
token
,后台通过拦截器校验token
是否有效。
多机登录
当 A 机器登录 user 后,B 机器也登录 user ,服务器也同样会生成一个 token
给 B 机器,此时 A \ B 机器同时登录 user 用户,并均可访问。即多机登录。
(2)不支持多机登录
- A 机器登录用户user,后台生成
token
返回前端,并将token
存储到redis
中。 - 当 A 机器再次请求时,带上
token
,后台通过拦截器校验redis
中以该用户id为key的token
是否与请求中的token
一致,如需要获取token
中的信息,还得解析token
。
单机原理(redis实现)
当 A 机器登录 user 后,
redis
中存入LOGINUSER:user1
为key的token
B 机器也登录 user 后,服务器也同样会生成一个
token
给 B 机器,并且在redis
中会将生成的token
覆盖掉 A机器存入的token
,此时 A 机器在访问时,A机器携带的token
就和redis
中的token
不一致,即被踢下线。即单机原理。
引入redis
用于实现单机原理。
1、实体类
@Data
@AllArgsConstructor
public class User {
private Long userId;
private String userName;
private String password;
}
2、登录接口
(1)controller
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private LoginService loginService;
@GetMapping("/login")
public Map<String, Object> login(User user){
HashMap<String, Object> map = new HashMap<>();
try{
User loginUser = loginService.login(user);
//生成token
HashMap<String,String> claim = new HashMap<>();
claim.put("userId",loginUser.getUserId()+"");
claim.put("userName",loginUser.getUserName());
String token = JwtUtil.getToken(claim);
redisTemplate.boundValueOps("TOKEN:"+loginUser.getUserId()).set(token);
map.put("user",loginUser);
map.put("status","认证成功");
map.put("token",token);
return map;
}catch (Exception e){
map.put("status",e.getMessage());
return map;
}
}
(2)service
@Override
public User login(User user) {
// 根据user查询数据库,此处仅为模拟
User userLogin = getUser(user);
if (userLogin != null){
return userLogin;
}
throw new RuntimeException("指定用户不存在");
}
3、认证拦截器
(1)拦截器
/**
* 登录校验
*/
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String,String> redisTemplate;
/**
* 验证是否登录
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HashMap<String, Object> map = new HashMap<>();
// 获取token
String token = request.getHeader("token");
try {
// 校验token
DecodedJWT decodedJWT = JwtUtil.getDecodedJWT(token);
String redisToken = redisTemplate.boundValueOps("TOKEN:" + decodedJWT.getClaim("userId").asString()).get();
if (token != null && token.equals(redisToken)){
request.setAttribute("userId",decodedJWT.getClaim("userId").asString());
request.setAttribute("userName",decodedJWT.getClaim("userName").asString());
return true; // 放行请求
}
map.put("msg","用户未登录");
} catch (SignatureVerificationException e) {
map.put("msg","签名不一致");
} catch (TokenExpiredException e){
map.put("msg","token过期");
}catch (AlgorithmMismatchException e){
map.put("msg","算法不匹配");
}catch (InvalidClaimException e){
map.put("msg","失效的payload");
}catch (IllegalArgumentException | JWTDecodeException e) {
map.put("msg", "token为空或格式错误");
} catch (Exception e){
map.put("msg","未知原因,登录失败");
}
map.put("status",false);
//将map转化为json , jackson
String json = new ObjectMapper().writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
return false;
}
}
(2)配置拦截器
@Configuration
//继承WebMvcConfigurer
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
//添加自定义拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 链式编程,第一个参数为 自定义的拦截器
registry.addInterceptor(loginInterceptor)
//配置拦截路径
.addPathPatterns("/**")
//配置不拦截的路径
.excludePathPatterns("/login","/test2")
//设置拦截器执行顺序
.order(1);
}
}
4、测试接口
@GetMapping("/test")
public Map test(HttpServletRequest request){
// 获取拦截器中设置的用户信息
String userId = (String)request.getAttribute("userId");
String userName = (String)request.getAttribute("userName");
HashMap<String, Object> map = new HashMap<>();
map.put("userId",userId);
map.put("userName",userName);
return map;
}
5、测试结果
(1)登录接口
认证成功
认证失败
(2)测试接口
登录成功
登录失败
四、jjwt 实现 JWT
除封装的工具类用法不同,其他基本同上
1、jjwt
(1)依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
(2)封装工具类
/**
* Copyright (c) 2016-2019 人人开源 All rights reserved.
*
* https://www.renren.io
*
* 版权所有,侵权必究!
*/
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* jwt工具类
*
* @author Mark sunlightcs@gmail.com
*/
// ConfigurationProperties 用于获取配置文件的属性值,并进行注入
@ConfigurationProperties(prefix = "renren.jwt")
@Component
public class JwtUtils {
private Logger logger = LoggerFactory.getLogger(getClass());
private String secret;
private long expire;
private String header;
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
logger.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public long getExpire() {
return expire;
}
public void setExpire(long expire) {
this.expire = expire;
}
public String getHeader() {
return header;
}
public void setHeader(String header) {
this.header = header;
}
}