代码已上传至github,如遇到问题,可参照代码
https://github.com/dfyang55/auth
以下介绍的只是一种思路,这种东西不是死的
1)加密实现
后台代码实现:CodecUtil
这里我生成两个AES的私钥,一个只是提高SHA1加密的复杂度(这个可以不要,或者可以说任意的,类似于盐),另一个才是用于AES的加解密
/** AES密钥长度,支持128、192、256 */
private static final int AES_SECRET_KEY_LENGTH = 128;
private static String generateAESSecretKeyBase64(String key) {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(AES_SECRET_KEY_LENGTH);
SecretKey secretKey = keyGenerator.generateKey();
return Base64Utils.encodeToString(secretKey.getEncoded());
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/** AES加密密钥 */
public static final byte[] AES_SECRET_KEY_BYTES = Base64Utils.decodeFromString("XjjkaLnlzAFbR399IP4kdQ==");
/** SHA1加密密钥(用于增加加密的复杂度) */
public static final String SHA1_SECRET_KEY = "QGZUanpSaSy9DEPQFVULJQ==";
使用AES实现加密解密
public static String aesEncrypt(String data) {
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // 加密算法/工作模式/填充方式
byte[] dataBytes = data.getBytes();
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(AES_SECRET_KEY_BYTES, "AES"));
byte[] result = cipher.doFinal(dataBytes);
return Base64Utils.encodeToString(result);
} catch (Exception e) {
log.error("执行CodecUtil.aesEncrypt失败:data={},异常:{}", data, e);
}
return null;
}
public static String aesDecrypt(String encryptedDataBase64) {
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // 加密算法/工作模式/填充方式
byte[] dataBytes = Base64Utils.decodeFromString(encryptedDataBase64);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(AES_SECRET_KEY_BYTES, "AES"));
byte[] result = cipher.doFinal(dataBytes);
return new String(result);
} catch (Exception e) {
log.error("执行CodecUtil.aesDecrypt失败:data={},异常:{}", encryptedDataBase64, e);
}
return null;
}
使用SHA1加密
public static String sha1Encrypt(String data) {
return DigestUtils.sha1Hex(data + SHA1_SECRET_KEY);
}
前端加密示例,这里是一个登陆的请求例子,先对数据进行加密,再用加密数据同时间戳和提高复杂度的AES密钥使用SHA1加密生成签名,最终将数据组装发送到后台。
注意这里有两个AES密钥,需要和后台对应。
$(function () {
$("#login_submit").click(function() {
var username = $("#username").val();
var password = $("#password").val();
if (username != undefined && username != null && username != ""
&& password != undefined && password != null && password != "") {
var loginJSON = JSON.stringify({"username": username,"password": password});
var encryptedData = CryptoJS.AES.encrypt(loginJSON, CryptoJS.enc.Base64.parse('XjjkaLnlzAFbR399IP4kdQ=='), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
length: 128
}).toString();
var timestamp = new Date().getTime();
var sign = CryptoJS.SHA1(encryptedData + timestamp + "QGZUanpSaSy9DEPQFVULJQ==").toString();
$.ajax({
url: "/user/login",
contentType: "application/json",
type: "post",
data: JSON.stringify({"sign": sign, "encryptedData": encryptedData, "timestamp": timestamp}),
dataType: "json",
success: function (data) {
document.cookie = "authToken=" + data.data.authToken;
}
})
} else {
alert("用户名或密码不能为空");
}
});
});
2)解密实现
首先创建一个类用于接收前端传过来的加密请求。
@Data
public class EncryptedReq<T> {
/** 签名 */
@NotBlank(message = "用户签名不能为空")
private String sign;
/** 加密请求数据 */
@NotBlank(message = "加密请求不能为空")
private String encryptedData;
/** 原始请求数据(解密后回填到对象) */
private T data;
/** 请求的时间戳 */
@NotNull(message = "时间戳不能为空")
private Long timestamp;
}
这里将使用AOP在切面中进行解密的操作,首先创建注解,在接收加密请求的接口方法上添加该注解,然后对该方法的EncryptedReq参数进行验签及解密。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DecryptAndVerify {
/** 解密后的参数类型 */
Class<?> decryptedClass();
}
AOP代码如下,具体步骤就是参数校验,验证及解密,数据回填。
@Slf4j
@Aspect
@Component
public class DecryptAndVerifyAspect {
@Pointcut("@annotation(com.dfy.auth.annotation.DecryptAndVerify)")
public void pointCut() {}
@Around("pointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) {
throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",参数为空");
}
EncryptedReq encryptedReq = null;
for (Object obj : args) {
if (obj instanceof EncryptedReq) {
encryptedReq = (EncryptedReq) obj;
break;
}
}
if (encryptedReq == null) {
throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",参数中无待解密类");
}
String decryptedData = decryptAndVerify(encryptedReq);
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
DecryptAndVerify annotation = methodSignature.getMethod().getAnnotation(DecryptAndVerify.class);
if (annotation == null || annotation.decryptedClass() == null) {
throw new DecryptAndVerifyException(joinPoint.getSignature().getName() + ",未指定解密类型");
}
encryptedReq.setData(JSON.parseObject(decryptedData, annotation.decryptedClass()));
return joinPoint.proceed();
}
private String decryptAndVerify(EncryptedReq encryptedReq) {
String sign = CodecUtil.sha1Encrypt(encryptedReq.getEncryptedData() + encryptedReq.getTimestamp());
if (sign.equals(encryptedReq.getSign())) {
return CodecUtil.aesDecrypt(encryptedReq.getEncryptedData());
} else {
throw new DecryptAndVerifyException("验签失败:" + JSON.toJSONString(encryptedReq));
}
}
}
最后写一个接口进行测试
@PostMapping("/login")
@ResponseBody
@DecryptAndVerify(decryptedClass = UserLoginReq.class)
public ResponseVo login(@RequestBody @Validated EncryptedReq<UserLoginReq> encryptedReq, HttpServletRequest request) {
UserLoginReq userLoginReq = encryptedReq.getData();
// TODO 从数据库核实用户登录信息,这里懒得查数据库了
if (userLoginReq.getUsername().equals("admin") && userLoginReq.getPassword().equals("admin")) {
request.getSession().setAttribute("username", userLoginReq.getUsername());
return ResponseVo.getSuccess();
} else {
return ResponseVo.getFailure(ResponseStatusEnum.USER_AUTH_FAILURE);
}
}
访问localhost:8080/user/login,传入加密数据,即可获得正确的响应(加密过程工具类有,这里不展示了)
{"encryptedData":"AN8LpQrOTFEFi8l4MQYyYriUDsKTwLhWtkaI9q6Ck/zjlm1PY/5rQObOeOAFBipY","sign":"ba8dac258b7802b9a407911524ba6f8448e8ea25","timestamp":1585702530560}
3)请求拦截
这里从上往下开始介绍,先介绍配置类。
这里配置了拦截所有路径,然后添加了三个启动参数,用于在拦截器中获取并分别进行处理。
@Configuration
public class MainConfig {
/** 不作拦截的URL路径 */
private String excludedURLPaths = "/index,/user/login,/user/register,/**/*.jpg,/**/*.css,/test/**";
private String logoutURL = "/user/logout";
private String loginURI = "/user/login";
@Bean
public FilterRegistrationBean filterRegistrationBean() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setFilter(new AuthFilter());
filterRegistrationBean.addInitParameter(AuthConstants.EXCLUDED_URI_PATHS, excludedURLPaths);
filterRegistrationBean.addInitParameter(AuthConstants.LOGOUT_URI, logoutURL);
filterRegistrationBean.addInitParameter(AuthConstants.LOGIN_URI, loginURI);
return filterRegistrationBean;
}
}
接下来介绍拦截器,具体逻辑就是判断请求是否需要拦截,如果需要判断用户是否登录,如果已登录判断是否为登出
public class AuthFilter implements Filter {
private static String loginURI;
private static String logoutURI;
/** 用于识别出不需要拦截的URI */
private static ExcludedURIUtil excludedURIUtil;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
String[] excludedURLPaths = filterConfig.getInitParameter(AuthConstants.EXCLUDED_URI_PATHS).split(",");
excludedURIUtil = ExcludedURIUtil.getInstance(excludedURLPaths);
logoutURI = filterConfig.getInitParameter(AuthConstants.LOGOUT_URI);
loginURI = filterConfig.getInitParameter(AuthConstants.LOGIN_URI);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestURI = request.getRequestURI();
if (excludedURIUtil.match(requestURI)) { // 如果请求URI不需要进行拦截
filterChain.doFilter(request, response);
return;
}
if (!UserLoginUtil.verify(request)) { // 如果用户未登录
response.sendRedirect(loginURI);
} else {
if (requestURI.equals(logoutURI)) { // 用户登出时删除相关数据
UserLoginUtil.logout(request);
}
filterChain.doFilter(request, response);
}
}
}
判断URI是否需要拦截这里使用的是正则表达式,将不需要拦截的URI转换为正则存起来,之后直接用请求的URI来匹配 (这里的正则也是现学现用,百度了半天写出来的,如果有更好的,可替代)
public class ExcludedURIUtil {
/** 单例 */
private static ExcludedURIUtil excludedUriUtil;
/** uri、正则表达式映射表 */
private static Map<String, String> uriRegexMap = new HashMap<String, String>();
private ExcludedURIUtil() {}
public static ExcludedURIUtil getInstance(String[] uris) {
if (excludedUriUtil == null) {
synchronized (ExcludedURIUtil.class) {
if (excludedUriUtil == null) {
excludedUriUtil = new ExcludedURIUtil();
if (uris != null && uris.length > 0) {
for (String uri : uris) {
String regex = uri2Regex(uri);
uriRegexMap.put(uri, regex);
}
}
}
}
}
return excludedUriUtil;
}
/**
* 判断给定uri是否匹配映射表中的正则表达式
*/
public boolean match(String uri) {
for (String regex : uriRegexMap.values()) {
if (uri.matches(regex)) {
return true;
}
}
return false;
}
/**
* 将URI转换为正则表达式
*/
public static String uri2Regex(String uri) {
int lastPointIndex = uri.lastIndexOf('.');
char[] uriChars = uri.toCharArray();
StringBuilder regexBuilder = new StringBuilder();
for (int i = 0, length = uriChars.length; i < length; i++) {
if (uriChars[i] == '*' && uriChars[i + 1] == '*') {
regexBuilder.append("(/[^/]*)*");
i++;
} else if (uriChars[i] == '*') {
regexBuilder.append("/[^/]*");
} else if (uriChars[i] == '.' && i == lastPointIndex) {
regexBuilder.append("\\.");
regexBuilder.append(uri.substring(i + 1));
break;
} else if (uriChars[i] == '/') {
if (!uri.substring(i + 1, i + 2).equals("*")) {
regexBuilder.append("/");
}
} else {
regexBuilder.append(uriChars[i]);
}
}
return regexBuilder.toString();
}
}
用户登录认证逻辑具体步骤归纳为:用户登录时认证信息正确则生成一个authToken返回,前端保存到cookie,当请求需要认证时从cookie中获取,没有再从header中获取,再判读authToken有效性。
public class UserLoginUtil {
/** 用户名,token缓存映射,有效时间2小时 */
private static Cache<String,String> usernameAuthTokenCache = CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.HOURS)
.build();
/**
* 为保证token的唯一性,这里使用用户名+UUID+时间戳,最后通过SHA1算法进行加密生成唯一token
*/
public static String generate(String username) {
StringBuilder sb = new StringBuilder();
sb.append(username)
.append(UUID.randomUUID().toString().replaceAll("-", ""))
.append(System.currentTimeMillis());
String authToken = CodecUtil.sha1Encrypt(sb.toString());
usernameAuthTokenCache.put(username, authToken);
return authToken;
}
/**
* 验证用户token是否存在或是否过期
*/
public static boolean isValid(String username, String authToken) {
if (username == null || authToken == null) return false;
String findAuthToken = usernameAuthTokenCache.getIfPresent(username);
return findAuthToken != null && authToken.equals(findAuthToken);
}
/**
* 验证用户当前登录是否登录(先从cookie中获取,再从header中获取)
*/
public static boolean verify(HttpServletRequest request) {
String username = (String) request.getSession().getAttribute("username");
for (Cookie cookie : request.getCookies()) {
if (cookie.getName().equals("authToken") && isValid(username, cookie.getValue())) {
return true;
}
}
String findAuthToken = request.getHeader("authToken");
if (isValid(username, findAuthToken)) {
return true;
}
return false;
}
/**
* 用户登出时删除缓存中的数据
*/
public static void logout(HttpServletRequest request) {
String username = (String) request.getSession().getAttribute("username");
usernameAuthTokenCache.invalidate(username);
}
}
最后修改我们的接口进行测试,如果我们直接访问/user/info则跳转登录页面,只有访问/user/login post成功登录后才能访问需认证的页面。
@Controller
@RequestMapping("/user")
public class UserController {
@GetMapping("/login")
public String login() {
return "/login";
}
@PostMapping("/login")
@ResponseBody
@DecryptAndVerify(decryptedClass = UserLoginReq.class)
public ResponseVo login(@RequestBody @Validated EncryptedReq<UserLoginReq> encryptedReq, HttpServletRequest request) {
System.out.println(request.getRequestURI());
UserLoginReq userLoginReq = encryptedReq.getData();
// TODO 从数据库核实用户登录信息,这里懒得查数据库了
if (userLoginReq.getUsername().equals("admin") && userLoginReq.getPassword().equals("admin")) {
request.getSession().setAttribute("username", userLoginReq.getUsername());
String authToken = UserLoginUtil.generate(userLoginReq.getUsername());
return ResponseVo.getSuccess(new UserLoginRes(authToken));
} else {
return ResponseVo.getFailure(ResponseStatusEnum.USER_AUTH_FAILURE);
}
}
@GetMapping("/info")
@ResponseBody
public ResponseVo info() {
return ResponseVo.getSuccess("用户信息");
}
}
详情可查看github:https://github.com/dfyang55/auth