如何使用Cookie来保存用户的登录信息
1.登录成功后生成Cookie响应给客户端
登录成功之后将登录用户的id,用户类型,用户来源平台,登录时间等信息序列化成字符串,然后再通过对称加密,生成一个字符串作为cookie的value值响应给客户端。
代码如下:
public void createCookieAndRegisterDevice(LoginVO loginVO, HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,String[] cookieDomains) throws Exception {
AuthCookieItem authCookieItem = CookieUtils.createCookie(userType2ApiType(loginVO.getUserType()), loginVO.getUserType(), loginVO.getUserId());
//设置登录时间戳
long now = System.currentTimeMillis();
authCookieItem.setTimestamp(now);
String deviceId = httpServletRequest.getHeader("DeviceId");
if (StringUtils.isNotBlank(deviceId)) {
doUserSysRedis(authCookieItem.getUserId(), now, deviceId);
}
String cookieValue = CookieUtils.encryptCookie(authCookieItem, AES.CookieKey).replaceAll("\\n|\\r", "");
writeCookie(httpServletResponse,cookieDomains,cookieValue);
}
AuthCookieItem对象就是存储用户登录信息的Cookie,类如下:
@Data
@Accessors(chain = true)
public class AuthCookieItem {
private ApiType apiType;
private UserType userType;
private long userId;
private long timestamp;
private Integer userPlatFormType;
}
CookieUtils.encryptCookie方法是将登录对象序列化成字符串并加密,内容如下:
public static String encryptCookie(AuthCookieItem authCookieItem, String key) throws Exception {
byte[] data = objectMapper.writeValueAsBytes(authCookieItem);
return AESUtils.encrypt(data, key);
}
另外我们的项目中,还校验了登录设备数,同一个账号只能在三台设备上登录。所以我们的请求都在请求头中传递了一个设备id,deviceId的参数,doUserSysRedis方法如下:
private void doUserSysRedis(Long userId, long now, String deviceId) {
String key = DEVICE_KEY + userId;
Map<String, DeviceInfo> deviceInfoMap = userSysRedis.hashEntries(key, new TypeReference<DeviceInfo>() {
});
int valids = 0;
Map<String, String> updateMap = new HashMap<>(16);
for (Map.Entry<String, DeviceInfo> entry : deviceInfoMap.entrySet()) {
DeviceInfo deviceInfo = entry.getValue();
if (deviceInfo.getValid() == DeviceStateEnum.NORMAL.getCode()) {
valids++;
updateMap.put(entry.getKey(), JsonUtils.writeObjectAsString(entry.getValue().setValid(DeviceStateEnum.OUT_OF_RANGE.getCode())));
}
}
if (valids >= AuthConstant.MAX_DEVICE_NUM) {
userSysRedis.hashPutAll(key, updateMap);
}
DeviceInfo deviceInfo = new DeviceInfo().setDeviceId(deviceId)
.setTimeStamp(now)
.setValid(DeviceStateEnum.NORMAL.getCode());
userSysRedis.hashPut(key, deviceId, deviceInfo);
//key设置有效期为100*12*30days
userSysRedis.expireKey(key, 100 * 12 * 30, TimeUnit.DAYS);
}
思路就是将每台设备的登录信息存储在redis的map数据结构中,用户id作为key,设备id作为map的key,DeviceInfo对象作为map的value,当登录设备数超过最大限定的设备数时,将之前所有登录过的设备踢下线,如果登录的设备数没有超过最大限定设备数,则将此设备正常注册到redis中。
public class DeviceInfo {
/**
* 设备唯一标识
*/
private String deviceId;
/**
* 是否有效(0无效,1有效)
*/
private Integer valid;
/**
* redis中注册信息的时间戳
*/
private Long timeStamp;
}
写cookie的具体操作writeCookie方法如下:
public void writeCookie(HttpServletResponse httpServletResponse, String[] cookieDomains, String cookieValue){
for (String cd : cookieDomains) {
Cookie cookie = new Cookie("SESS", cookieValue);
cookie.setPath("/");
cookie.setDomain(cd);
httpServletResponse.addCookie(cookie);
}
}
domain的内容是:
String[] domains = new String[]{"*"};
2.请求任何一个带鉴权的接口都校验cookie和登录设备数,判断是否合法
自定义切面拦截器进行鉴权的代码如下:
@Slf4j
@Aspect
@Component
public class AuthInterceptor {
@Autowired
private UserSysRedis userSysRedis;
@Autowired
private CommonRedis commonRedis;
@Value("${spring.profiles.active}")
private String profile;
@Pointcut("execution(* com.包名..*(..)) && @annotation(com.包名.auth.annotation.AuthRequired)")
public void loginRequiredMethodPointCut() {
}
@Pointcut("execution(* com.包名..*(..)) && @within(com.包名.auth.annotation.AuthRequired) && @annotation(org.springframework.web.bind.annotation.RequestMapping)")
public void authRequiredClassPointCut() {
}
@Around("authRequiredClassPointCut()")
public Object authRequiredClassIntercept(ProceedingJoinPoint pjp) throws Throwable {
return authRequiredIntercept(pjp);
}
@Around("loginRequiredMethodPointCut()")
public Object authRequiredMethodIntercept(ProceedingJoinPoint pjp) throws Throwable {
return authRequiredIntercept(pjp);
}
public Object authRequiredIntercept(ProceedingJoinPoint pjp) throws Throwable {
Object target = pjp.getTarget();
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
Class clazz = method.getDeclaringClass();
AuthRequired authRequired = method.getAnnotation(AuthRequired.class);
if (authRequired == null) {
authRequired = (AuthRequired) clazz.getAnnotation(AuthRequired.class);
}
if (!(target instanceof BaseController)) {
throw new UnsupportedOperationException("auth require target must be BaseController");
}
BaseController bc = (BaseController) target;
;
HttpServletRequest httpServletRequest = bc.getHttpServletRequest();
//校验服务器维护状态
checkServerStatus(httpServletRequest);
final String cookieValue = Cookies.getCookie(httpServletRequest, "SESS");
log.info("cookie value:{}", cookieValue);
if (cookieValue == null && !authRequired.optional()) {
throw new UnauthorizedException("Unauthorized");
}
//从cookie中反序列化出登录信息
AuthCookieItem authCookieItem = get(cookieValue);
//检查cookie是否过期
checkCookieDated(httpServletRequest, authCookieItem);
//将用户登录信息设置进request中,使用时可以根据request.getAttribute获取
httpServletRequest.setAttribute(AUTH_COOKIE_ITEM_NAME, authCookieItem);
//将登录用户id设置进ThreadLocal中
GlobalRequestContext.setUserId(authCookieItem.getUserId());
/*判断登录设备数*/
checkDeviceNum(httpServletRequest, authCookieItem.getUserId());
return pjp.proceed();
}
private AuthCookieItem get(String cookieValue) throws Exception {
try {
return CookieUtils.decryptCookie(cookieValue, AES.CookieKey);
} catch (Exception e) {
throw new UnauthorizedException("not authorized");
}
}
/**
* 校验服务器维护状态
*/
private void checkServerStatus(HttpServletRequest httpServletRequest) {
String deviceId = httpServletRequest.getHeader("DeviceId");
if (StringUtils.isEmpty(deviceId)) {
return;
}
MaintainPrediction mp = commonRedis.get(AuthConstant.SERVER_STATUS_CHANGE_KEY, MaintainPrediction.class);
long now_time = System.currentTimeMillis();
if (!ObjectUtils.isEmpty(mp) && now_time > mp.getStartTime() && now_time < mp.getEndTime()) {
throw new ServiceUnvaliableException(mp.getInDescription());
}
}
/**
* 校验cookie是否失效
*
* @param authCookieItem
*/
private void checkCookieDated(HttpServletRequest httpServletRequest, AuthCookieItem authCookieItem) {
String deviceId = httpServletRequest.getHeader("DeviceId");
if (!StringUtils.isEmpty(deviceId) && authCookieItem.getTimestamp() > 0
&& (authCookieItem.getTimestamp() + AuthConstant.MAX_COOKIE_DURATION < System.currentTimeMillis())) {
log.info("desc:{},userId:{},deviceId:{}", "用户cookie已超时[AuthInterceptor]", authCookieItem.getUserId(), deviceId);
String key = AuthConstant.DEVICE_KEY + authCookieItem.getUserId();
DeviceInfo deviceInfo = userSysRedis.hashGet(key, deviceId, new TypeReference<DeviceInfo>() {
});
if (!ObjectUtils.isEmpty(deviceInfo)) {
userSysRedis.hashPut(key, deviceId, deviceInfo.setValid(DeviceStateEnum.OUT_OF_DATE.getCode()));
}
throw new UnauthorizedException("login out of date");
}
}
/**
* 校验用户登录设备数
*
* @param httpServletRequest
* @param userId
* @return
*/
private void checkDeviceNum(HttpServletRequest httpServletRequest, long userId) {
String deviceId = httpServletRequest.getHeader("DeviceId");
if (StringUtils.isEmpty(deviceId)) {
return;
}
String key = AuthConstant.DEVICE_KEY + userId;
/*该设备id已注册*/
if (userSysRedis.hashHasKey(key, deviceId)) {
DeviceInfo deviceInfo = userSysRedis.hashGet(key, deviceId, new TypeReference<DeviceInfo>() {
});
//该设备能有效访问
if (deviceInfo.getValid() == DeviceStateEnum.NORMAL.getCode()) {
return;
}
//该账号已超过最大登录设备数限制,当前设备被提醒并需重新登录
if (deviceInfo.getValid() == DeviceStateEnum.OUT_OF_RANGE.getCode()) {
throw new ForbiddenException("登录设备数超过限制");
}
//该账号在其它设备上执行了修改密码操作,当前设备需强制重新登录
if (deviceInfo.getValid() == DeviceStateEnum.PASSWORD_CHANGED.getCode()) {
throw new UnauthorizedException("密码在其他设备上被修改,需重新登录");
}
}
/*该设备id未注册*/
Map<String, DeviceInfo> deviceInfoMap = userSysRedis.hashEntries(key, new TypeReference<DeviceInfo>() {
});
int valids = 0;
Map<String, String> updateMap = new HashMap<>(16);
for (Map.Entry<String, DeviceInfo> entry : deviceInfoMap.entrySet()) {
DeviceInfo deviceInfo = entry.getValue();
if (deviceInfo.getValid() == DeviceStateEnum.NORMAL.getCode()) {
valids++;
updateMap.put(entry.getKey(), JsonUtils.writeObjectAsString(entry.getValue().setValid(DeviceStateEnum.OUT_OF_RANGE.getCode())));
}
}
if (valids >= AuthConstant.MAX_DEVICE_NUM) {
userSysRedis.hashPutAll(key, updateMap);
}
DeviceInfo deviceInfo = new DeviceInfo().setDeviceId(deviceId)
.setTimeStamp(System.currentTimeMillis())
.setValid(DeviceStateEnum.NORMAL.getCode());
userSysRedis.hashPut(key, deviceId, deviceInfo);
//key设置有效期为30days
userSysRedis.expireKey(key, 30, TimeUnit.DAYS);
}
}
GlobalRequestContext的内容如下:
public class GlobalRequestContext {
private static final ThreadLocal<String> ACCESS_TOKEN = new ThreadLocal<>();
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<AtomicLong> DB_COST_TIME = ThreadLocal.withInitial(AtomicLong::new);
public static Long getUserId() {
return USER_ID.get();
}
public static void setUserId(long userId) {
USER_ID.remove();
USER_ID.set(userId);
}
public static String getRequestId() {
return REQUEST_ID.get();
}
public static void setRequestId(String requestId) {
REQUEST_ID.remove();
REQUEST_ID.set(requestId);
}
static Long getDbCostTime() {
return DB_COST_TIME.get().get();
}
static void setDbCostTime(long dbCostTime) {
DB_COST_TIME.get().addAndGet(dbCostTime);
}
static void clear() {
DB_COST_TIME.remove();
}
public static String getAccessToken() {
return ACCESS_TOKEN.get();
}
public static void setAccessToken(String accessToken) {
ACCESS_TOKEN.remove();
ACCESS_TOKEN.set(accessToken);
}
}
自定义注解AuthRequired的注解内容如下:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthRequired {
enum AuthType {
Server(null),
Student(UserType.Student),
Teacher(UserType.Teacher),
SchoolMaster(UserType.SchoolMaster),
Operator(UserType.Operator),
Parent(UserType.Parent),
Franchisee(UserType.Franchisee);
private UserType userType;
AuthType(UserType userType) {
this.userType = userType;
}
public UserType getUserType() {
return userType;
}
}
AuthType[] value() default {};
boolean optional() default false;
}
3.使用
使用时在每个需要鉴权的地方,只要带上鉴权注解即可。
@AuthRequired(AuthRequired.AuthType.Student)
@PostMapping("/update")
public Response<Boolean> update() {
Response<Boolean> response = new Response<>();
long userId = getAuthCookieItem().getUserId();
try {
//业务逻辑
} catch (Exception e) {
//异常处理
}
return response;
}
getAuthCookieItem().getUserId();是为了获取登录用户id的,还可以通过GlobalRequestContext.getUserId();来获取,因为已经把用户id塞进ThreadLocal上下文了。
可以看到我这里虽然使用了redis,但是判断用户登录信息的并没有使用redis,只是在判断用户的登录设备数或服务是否是维护状态才用到redis,如果你的服务不需要判断这两样,是不需要用户redis的,根据自己的业务决定。整个流程就是登录时将用户的登录信息序列化成字符串加密返回给客户端,客户端在请求后台接口时带上这个Cookie,统一走后台的切面鉴权逻辑,从传入的Cookie值中解密并反序列化出登录信息,判断登录状态是否有效,有效则放行,无效则抛出异常,前端统一跳进重新登录页面。