注解+aop实现登录验证
一、需求简介
现在有一个前后端分离的项目,使用jwt进行校验,用户登录之后返回token,前端拿到token放在本地中,每次请求都要带上这个token放在请求头中。现在的需求是对一些请求进行登录验证,项目中的controller中有一些方法必须登录才能请求,比如下单,还有些方法不用登录就能请求,比如浏览商品,还有些方法登录与未登录是不同的响应:比如登录之后不投放广告,未登录就要投放广告。要实现这些功能,需要自定义注解并结合SpringAOP。
二、jwt的使用
1、简介
jwt的全称是JSON Web Token,JSON Web令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为JSON对象安全地传输信息。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对对JWT进行签名。
2、适用情景
- 授权:这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单点登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。
- 信息交换:JSON Web令牌是在各方之间安全地传输信息的好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确定发件人是他们所说的人。另外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否未被篡改。
3、令牌结构
JSON Web令牌以紧凑的形式由三部分组成,这些部分由点(.)分隔,分别是:
- 标头
- 有效载荷
- 签名
因此,JWT通常如下所示。
xxxxx.yyyyy.zzzzz
3.1 标头
标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后,此JSON被Base64Url编码以形成JWT的第一部分。
3.2 有效载荷
令牌的第二部分是有效负载,其中包含声明(claim)。声明是有关实体(通常是用户)和其他数据的声明。声明有以下三种类型:注册的,公共的和私人声明。
有效负载示例可能是:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后,对有效负载进行Base64Url编码,以形成JSON Web令牌的第二部分。
注意:由于Base64编码是可逆的,所以一旦外界获取到token就可以解码,标头和有效载荷的原始内容就可以获取到,所以不要再有效载荷中放入敏感信息,比如密码等。
3.3 签名
要创建签名部分,您必须获取编码的标头,编码的有效载荷,密钥,标头中指定的算法,并对其进行签名。
例如,如果要使用HMAC SHA256算法,则将通过以下方式创建签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
签名用于验证消息在整个过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证JWT的发送者是它所说的真实身份。
3.4 组合在一起
输出是三个由点分隔的Base64-URL字符串,可以在HTML和HTTP环境中轻松传递这些字符串,与基于XML的标准(例如SAML)相比,它更紧凑。
下图显示了一个JWT,它已对先前的标头和有效负载进行了编码,并用一个秘密签名。
编码的JWT
4、使用
4.1 添加依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
4.2 编写JWTUtil工具类
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.io.UnsupportedEncodingException;
import java.util.Date;
/**
* @author huwen
*/
public class JWTUtil {
/**
* 设置过期时间24小时
*/
private static final long EXPIRE_TIME = 1000*60*60*24;
/**
* 设置密钥
*/
private static final String SECRET = "xbzkhdsd8sd783ends8o8woe9";
/**
* 根据用户名创建一个token
* @param username 用户名
* @return 返回的token字符串
*/
public static String createToken(String username){
try {
//将当前时间的毫秒数和设置的过期时间相加生成一个新的时间
Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME);
//由密钥创建一个指定的算法
Algorithm algorithm = Algorithm.HMAC256(SECRET);
return JWT.create()
//附带username信息
.withClaim("username",username)
//附带过期时间
.withExpiresAt(date)
//使用指定的算法进行签名
.sign(algorithm);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return null;
}
}
/**
* 验证token是否正确
* @param token 前端传过来的token
* @param username 用户名
* @return 返回boolean
*/
public static boolean verify(String token,String username){
try {
//获取算法
Algorithm algorithm = Algorithm.HMAC256(SECRET);
//生成JWTVerifier
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username",username)
.build();
//验证token
verifier.verify(token);
return true;
} catch (UnsupportedEncodingException | TokenExpiredException e) {
e.printStackTrace();
return false;
}
}
/**
* 从token中获得username,无需secret
* @param token token
* @return username
*/
public static String getUsername(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
}
这个类有两个静态常量:EXPIRE_TIME与SECRET,分别代表了token的过期时间和密钥,token的有效期是创建token的时间加上过期时间,如果超过了这个时间点验证token时就会报异常。这个类有三个方法:第一个方法是生成token的方法,根据传过来的用户名结合过期时间以及算法名和密钥产生一个token。第二个方法根据用户名验证token是否正确,可能抛出验证错误和token过期的异常,返回值如果验证成功返回true,否则返回false。第三个方法对token进行Base64解码然后获得username。
5、编写常量以及ResultData
由于是前后端分离的项目,所以在向前端返回数据的时候需要返回一个对象,这个对象包含状态码、信息以及数据。
这个类定义了状态码的常量值,0表示请求成功,1表示失败。
public class StatusCode {
public static final int SUCCESS = 0;
public static final int FAIL = -1;
}
阿里巴巴编码规范中有一条是不要使用魔法值(未经预先定义的常量值),原因就在于代码的可读性较差,此外如果要修改就需要修改多处,所以提前在代码中定义好常量值是一个好的编程习惯。
这个类定义了常量信息
package com.qianfeng.common;
public class ConstantMessage {
public static final String REQUEST_ERROR = "请求异常";
public static final String LOGIN_FAIL = "登录失败";
public static final String REQUEST_SUCCESS = "请求成功";
public static final String USERNAME_NOT_EXISTS = "用户名不存在";
public static final String INCORRECT_PASSWORD = "密码不正确";
}
这个类使用了泛型,根据传入参数的不同,成员变量data的属性也不同。
package com.qianfeng.entity;
import lombok.Data;
@Data
public class ResultData <T>{
private Integer code;
private String message;
private T data;
}
6、编写注解与切面类
首先,要想在SpringBoot中使用AOP,需要添加AOP的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
6.1 注解介绍
注解有什么用?
注解本质上没有任何功能和意义,通常只是用来对类或者类的各种成员进行标记。但是一个架构中,往往可以针对不同标记的类或者成员进行不同的处理(反射),以此来达到对业务功能灵活处理的效果。
自定义注解的语法
//标记一些元注解 - 标记注解的注解
@Documented
public @interface IsLogin {
//设置注解的方法 - 没有方法体
String method() default "Hello";
}
注解的基本元注解
@Documented - 表示该注解会被javadoc文档收录
@Target - 表示当前注解的作用范围,可选值
ElementType.CONSTRUCTOR - 表示可以标记构造方法
ElementType.FIELD - 表示可以标记成员变量(类属性)
ElementType.LOCAL_VARIABLE - 表示可以标记局部变量
ElementType.METHOD - 表示可以标记方法
ElementType.PARAMETER - 表示可以标记方法参数
ElementType.TYPE - 表示可以标记类、接口、内部类等@Retention - 表示当前注解的有效范围,可选值
RetentionPolicy.SOURCE - 注解在源码中有效,编译成class文件后丢失
RetentionPolicy.CLASS - 注解在源码和编译文件中有效,运行时丢失
RetentionPolicy.RUNTIME - 注解在源码、编译文件以及运行时都有效,如果需要配合反射使用,则必须是这个范围@Inherited - 表示当前注解具有继承性,父类标记了该注解,则子类自动继承该注解
注解中的方法
//注解中可以包含0~N个方法
//语法
返回值类型 方法名称() [default 默认值];
注意:
1、方法一定没有参数
2、方法可以设置默认值,也可以不设置
3、如果方法没有设置默认值,则必须在使用注解时指定
4、如果一个方法的名称,刚好加value,这使用注解时,就可以不指定名称赋值了
5、如果使用注解时,需要设置两个以上的方法时,那么value就不能再省略了
6、方法的返回值可以是数组,那么在指定时,就必须给定数组,但是如果数据只有一个元素,{}可以省略不写
7、注解中的方法是没有任何方法体
6.2 注解使用
这个注解只能用在方法上,而且在运行时有效,有一个方法mustLogin默认返回值是false,表示不需要登录。
package com.qianfeng.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author huwen
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface IsLogin {
boolean mustLogin() default false;
}
切面类:
这个切面类上加了@Component注解保证可以被扫描到,同时加上@Aspect注解,表明这是一个切面类
package com.qianfeng.aop;
import com.qianfeng.common.ConstantMessage;
import com.qianfeng.common.StatusCode;
import com.qianfeng.entity.ResultData;
import com.qianfeng.entity.ResultMap;
import com.qianfeng.entity.UserThreadLocal;
import com.qianfeng.util.JWTUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
@Component
@Aspect
public class LoginAop {
@Pointcut("@annotation(com.qianfeng.aop.IsLogin)")
public void pointCut(){}
/**
* 或者使用下面的方式
* //@Around("@annotation(IsLogin)")
*/
@Around("pointCut()")
public ResultData<Object> loginAround(ProceedingJoinPoint joinPoint){
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = servletRequestAttributes.getRequest();
String token = request.getHeader("token");
String username = null;
if(token!=null){
String s = JWTUtil.getUsername(token);
if (JWTUtil.verify(token, s)) {
username = s;
}
}
//如果用户名为空,说明请求头中没有token或者token已经过期或者token验证没有通过
if(username==null){
//如果没有登录
//获得方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//获得注解
IsLogin annotation = signature.getMethod().getAnnotation(IsLogin.class);
//获得注解中的属性值,如果必须登录并且验证没有通过
if (annotation.mustLogin()) {
ResultData<Object> resultData = new ResultData<>();
resultData.setCode(StatusCode.FAIL);
resultData.setMessage(ConstantMessage.REQUEST_ERROR);
resultData.setData(null);
return resultData;
}
}
//走到这里说明不用登录或者用户已经登录
UserThreadLocal.setUser(username);
ResultData<Object> resultData = null;
try {
resultData = (ResultData<Object>) joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
return resultData;
}
}
切面类中有五种增强方式:
实现增强:
1、前置增强 @Before
2、后置增强 @After
3、环绕增强 @Around
4、后置完成增强 @AfterReturning
5、异常增强 @AfterThrowing
环绕增强可以首先其他四种增强,所以也是使用最为频繁的增强。
关于切点表达式的用法,既可以直接定义切点表达式,也可以先顶一个一个切点方法,再重用。就像上面的一样:
方式一:
@Pointcut("@annotation(com.qianfeng.aop.IsLogin)")
public void pointCut(){}@Around("pointCut()")
方式二:
@Around("@annotation(IsLogin)")
现在的想法是先判断加上这个注解的方法的请求的请求头中是否带有token,如果有token并且校验成功,就把username保存起来。然后判断username是否为空,如果为空,说明没有token或者token已经过期或者token被篡改,在这种情况下判断mustLogin的返回值是否为true,如果为true说明必须要登录,直接返回一个ResultData告诉前台需要登录。如果为false说明不需要登录,继续往下走,走到这里我们说明不用登录或者用户验证已经通过,将username存起来以便controller使用,这个地方使用了ThreadLocal类保证线程安全。
package com.qianfeng.entity;
public class UserThreadLocal {
private static ThreadLocal<String> userThreadLocal = new ThreadLocal<>();
public static void setUser(String user){
userThreadLocal.set(user);
}
public static String getUser(){
return userThreadLocal.get();
}
}
使用ThreadLocal类修饰的变量每个线程都会复制一份到自己的内存区,各个线程之间相互独立,互不干扰,可以实现线程同步。
7、编写controller
package com.qianfeng.controller;
import com.qianfeng.aop.IsLogin;
import com.qianfeng.common.ConstantMessage;
import com.qianfeng.common.StatusCode;
import com.qianfeng.entity.ResultData;
import com.qianfeng.entity.UserThreadLocal;
import com.qianfeng.util.JWTUtil;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class UserController {
private static final Map<String,String> USER_MAP = new HashMap<>();
static {
USER_MAP.put("zhangsan","zhangsan123");
USER_MAP.put("lisi","lisi123");
}
@PostMapping("/login")
public ResultData<String> login(@RequestParam("username") String username,
@RequestParam("password") String password){
String s = USER_MAP.get(username);
ResultData<String> resultData = new ResultData<>();
if(s==null){
resultData.setCode(StatusCode.FAIL);
resultData.setData(null);
resultData.setMessage(ConstantMessage.USERNAME_NOT_EXISTS);
} else if (!s.equals(password)){
resultData.setCode(StatusCode.FAIL);
resultData.setData(null);
resultData.setMessage(ConstantMessage.INCORRECT_PASSWORD);
}
else {
resultData.setCode(StatusCode.SUCCESS);
resultData.setData(JWTUtil.createToken(username));
resultData.setMessage(ConstantMessage.REQUEST_SUCCESS);
}
return resultData;
}
@GetMapping("/order")
@IsLogin(mustLogin = true)
public ResultData<Object> order(){
System.out.println(UserThreadLocal.getUser());
ResultData<Object> resultData = new ResultData<>();
resultData.setCode(0);
resultData.setData(null);
resultData.setMessage("下单成功");
return resultData;
}
}
这个controller有一个登录的方法还有一个下单的方法,登录方法会对各种情况进行判断:用户名不存在、密码错误等,如果登陆成功会生成一个token并返回给前端页面。
还有一个方法是下单的方法,必须要登录,所以加上了IsLogin注解并且mustLogin设为了true。