一、理论基础
(1)
- 理解redis和token的使用
- 使用token主要是为了安全,还有就是可以让安卓、IOS、PC等多端登录。用户每一个端登陆成功之后,会对应生成一个token令牌(临时且唯一)存放在redis中作为redis的key ,value 作为userid存储;根据userId+loginType 查询当前登陆类型账号之前是否有登陆过,如果登陆过 清除之前redis token。
- 如果是PC端,token存放在PC端 的cookie ;如果在安卓 或者IOS端,token 存放在本地文件中。
- 当前存在那些问题? ——用户如果退出或者修改密码、忘记密码的情况 下,需要对token状态进行标识以下,防止被篡改
(3)@Transactional注解属于声明式事务 还是 编程式事务?
答:声明式事务。加该注解不能控制redis事务,所以涉及到redis的事务要自定义方法,使用编程式事务,在begin和commit之间即需要控制数据库事务也需要控制redis事务;调用begin方式时会同时开始数据库事务和redis事务,commit、callback时也是同时的。(编程式事务相对于声明式事务唯一的优点就是事务粒度更精细)
(2)Redis删除Token与数据库状态Token如何保持一致?
答:redis本身也是支持事务的,只需要让redis事务和数据库事务保持一致,双方都是同步的即可(答案在下文)
二、代码实战
2.1、唯一登录实战
例子:会员登录(防止重复登录)
(1)登陆唯一登录表设计
CREATE TABLE `user_token` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`token` varchar(255) DEFAULT NULL,
`login_type` varchar(255) CHARACTER SET utf8 DEFAULT NULL,
`device_infor` varchar(255) DEFAULT NULL,
`is_availability` int(2) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2
(2)Mapper
public interface UserMapper {
int register(UserDo userDo);
UserDo existMobile(@Param("mobile") String mobile);
UserDo login(@Param("mobile") String mobile, @Param("password") String password);
UserDo findByUserId(@Param("userId") Long userId);
}
public interface UserTokenMapper {
UserTokenDo selectByUserIdAndLoginType(@Param("userId") Long userId, @Param("loginType") String loginType);
int updateTokenAvailability(@Param("userId") Long userId, @Param("loginType") String loginType);
int insertUserToken(UserTokenDo userTokenDo);
}
(3)Dao
@Data
public class UserTokenDo extends BaseDo {
/**
* id
*/
private Long id;
/**
* 用户token
*/
private String token;
/**
* 登陆类型
*/
private String loginType;
/**
* 设备信息
*/
private String deviceInfor;
/**
* 用户userId
*/
private Long userId;
/**
* 注册时间
*/
private Date createTime;
/**
* 修改时间
*
*/
private Date updateTime;
}
@Data
public class BaseDo {
/**
* 注册时间
*/
private Date createTime;
/**
* 修改时间
*
*/
private Date updateTime;
/**
* id
*/
private Long id;
/**
* 是否可用 0可用 1不可用
*/
private Long isAvailability;
}
(4)生成token的工具类:GenerateToken
根据token获取redis中的value值
@Component
public class GenerateToken {
@Autowired
private RedisUtil redisUtil;
/**
* 生成令牌
*
* @param prefix
* 令牌key前缀
* @param redisValue
* redis存放的值
* @return 返回token
*/
public String createToken(String keyPrefix, String redisValue) {
return createToken(keyPrefix, redisValue, null);
}
/**
* 生成令牌
*
* @param prefix
* 令牌key前缀
* @param redisValue
* redis存放的值
* @param time
* 有效期
* @return 返回token
*/
public String createToken(String keyPrefix, String redisValue, Long time) {
if (StringUtils.isEmpty(redisValue)) {
new Exception("redisValue Not nul");
}
String token = keyPrefix + UUID.randomUUID().toString().replace("-", "");
redisUtil.setString(token, redisValue, time);
return token;
}
/**
* 根据token获取redis中的value值
*
* @param token
* @return
*/
public String getToken(String token) {
if (StringUtils.isEmpty(token)) {
return null;
}
String value = redisUtil.getString(token);
return value;
}
/**
* 移除token
*
* @param token
* @return
*/
public Boolean removeToken(String token) {
if (StringUtils.isEmpty(token)) {
return null;
}
return redisUtil.delKey(token);
}
}
(5)新增常量信息
// token
String MEMBER_TOKEN_KEYPREFIX = "mayikt.member.login";
// 安卓的登陆类型
String MEMBER_LOGIN_TYPE_ANDROID = "Android";
// IOS的登陆类型
String MEMBER_LOGIN_TYPE_IOS = "IOS";
// PC的登陆类型
String MEMBER_LOGIN_TYPE_PC = "PC";
// 登陆超时时间 有效期 90天
Long MEMBRE_LOGIN_TOKEN_TIME = 77776000L;
(6)唯一登陆接口实现
DTO:
@Data
@ApiModel(value = "用户登陆参数")
public class UserLoginInpDTO {
/**
* 手机号码
*/
@ApiModelProperty(value = "手机号码")
private String mobile;
/**
* 密码
*/
@ApiModelProperty(value = "密码")
private String password;
/**
* 登陆类型 PC、Android 、IOS
*/
@ApiModelProperty(value = "登陆类型")
private String loginType;
/**
* 设备信息
*/
@ApiModelProperty(value = "设备信息")
private String deviceInfor;
}
Service:
@Api(tags = "用户登陆服务接口")
public interface MemberLoginService {
/**
* 用户登陆接口
*
* @param userEntity
* @return
*/
@PostMapping("/login")
@ApiOperation(value = "会员用户登陆信息接口")
BaseResponse<JSONObject> login(@RequestBody UserLoginInpDTO userLoginInpDTO);
}
Service的impl:
@RestController
public class MemberLoginServiceImpl extends BaseApiService<JSONObject> implements MemberLoginService {
@Autowired
private UserMapper userMapper;
@Autowired
private GenerateToken generateToken;
@Autowired
private UserTokenMapper userTokenMapper;
@Autowired
private RedisDataSoureceTransaction redisDataSoureceTransaction;
@Autowired
private RedisUtil redisUtil;
public BaseResponse<JSONObject> login(@RequestBody UserLoginInpDTO userLoginInpDTO) {
// 1.验证参数
String mobile = userLoginInpDTO.getMobile();
if (StringUtils.isEmpty(mobile)) {
return setResultError("手机号码不能为空!");
}
String password = userLoginInpDTO.getPassword();
if (StringUtils.isEmpty(password)) {
return setResultError("密码不能为空!");
}
// 判断登陆类型
String loginType = userLoginInpDTO.getLoginType();
if (StringUtils.isEmpty(loginType)) {
return setResultError("登陆类型不能为空!");
}
// 目的是限制范围
if (!(loginType.equals(Constants.MEMBER_LOGIN_TYPE_ANDROID) || loginType.equals(Constants.MEMBER_LOGIN_TYPE_IOS)
|| loginType.equals(Constants.MEMBER_LOGIN_TYPE_PC))) {
return setResultError("登陆类型出现错误!");
}
// 设备信息
String deviceInfor = userLoginInpDTO.getDeviceInfor();
if (StringUtils.isEmpty(deviceInfor)) {
return setResultError("设备信息不能为空!");
}
// 2.对登陆密码实现加密
String newPassWord = MD5Util.MD5(password);
// 3.使用手机号码+密码查询数据库 ,判断用户是否存在
UserDo userDo = userMapper.login(mobile, newPassWord);
if (userDo == null) {
return setResultError("用户名称或者密码错误!");
}
// 用户登陆Token Session 区别
// 用户每一个端登陆成功之后,会对应生成一个token令牌(临时且唯一)存放在redis中作为rediskey value userid
TransactionStatus transactionStatus = null;
try {
// 4.获取userid
Long userId = userDo.getUserId();
// 5.根据userId+loginType 查询当前登陆类型账号之前是否有登陆过,如果登陆过 清除之前redistoken
UserTokenDo userTokenDo = userTokenMapper.selectByUserIdAndLoginType(userId, loginType);
//开启以下事务
transactionStatus = redisDataSoureceTransaction.begin();
if (userTokenDo != null) {
// 如果登陆过 清除之前redistoken
String token = userTokenDo.getToken();
// 如果开启redis事务的话,删除的时候 方法会返回false
Boolean removeToken = generateToken.removeToken(token);
// 把该token的状态改为1
int updateTokenAvailability = userTokenMapper.updateTokenAvailability(token);
if (!toDaoResult(updateTokenAvailability)) {
return setResultError("系统错误!");
}
}
// .生成对应用户令牌存放在redis中
// 1.插入新的token
UserTokenDo userToken = new UserTokenDo();
userToken.setUserId(userId);
userToken.setLoginType(userLoginInpDTO.getLoginType());
String keyPrefix = Constants.MEMBER_TOKEN_KEYPREFIX + loginType;
String newToken = generateToken.createToken(keyPrefix, userId + "");
userToken.setToken(newToken);
userToken.setDeviceInfor(deviceInfor);
int insertUserToken = userTokenMapper.insertUserToken(userToken);
if (!toDaoResult(insertUserToken)) {
redisDataSoureceTransaction.rollback(transactionStatus);
return setResultError("系统错误!");
}
JSONObject data = new JSONObject();
data.put("token", newToken);
//没有任何问题才commit
redisDataSoureceTransaction.commit(transactionStatus);
return setResultSuccess(data);
} catch (Exception e) {
try {
//有问题则回滚
redisDataSoureceTransaction.rollback(transactionStatus);
} catch (Exception e2) {
// TODO: handle exception
}
return setResultError("系统错误!");
}
}
// 查询用户信息的话如何实现? redis 与数据库如何保证一致问题
@Override
public BaseResponse<JSONObject> delToken(String token) {
if (StringUtils.isEmpty(token)) {
return setResultError("token不能为空!");
}
Boolean delKey = redisUtil.delKey(token);
return delKey ? setResultSuccess("删除成功") : setResultError("删除失败!");
}
// redis 的值如何与数据库的值保持是一致性问题
// @Transactional 不能控制redis的事务
// redis 中是否存在事务 肯定是肯定是存在事务
// 自定义方法 使用编程事务 begin(既需要控制数据库的事务也需要控制redis) commit
}
疑问:查询用户信息的话如何实现? redis 与数据库如何保证一致问题?
参考:https://blog.csdn.net/RuiKe1400360107/article/details/103706472
以下代码其实可以封装成一个注解:
//开启一下事务
transactionStatus = redisDataSoureceTransaction.begin();......
//没有任何问题才commit
redisDataSoureceTransaction.commit(transactionStatus);
return setResultSuccess(data);
} catch (Exception e) {
try {
//有问题则回滚
redisDataSoureceTransaction.rollback(transactionStatus);
} catch (Exception e2) {
// TODO: handle exception
}
return setResultError("系统错误!");
Redis与 DataSource 事务封装:
@Component
@Scope(ConfigurableListableBeanFactory.SCOPE_PROTOTYPE)
public class RedisDataSoureceTransaction {
@Autowired
private RedisUtil redisUtil;
/**
* 数据源事务管理器
*/
@Autowired
private DataSourceTransactionManager dataSourceTransactionManager;
/**
* 开始事务 采用默认传播行为
*
* @return
*/
public TransactionStatus begin() {
// 手动begin数据库事务
// 1.开启数据库的事务 事务传播行为
TransactionStatus transaction = dataSourceTransactionManager.getTransaction(new DefaultTransactionAttribute());
// 2.开启redis事务
redisUtil.begin();
return transaction;
}
/**
* 提交事务
*
* @param transactionStatus
* 事务传播行为
* @throws Exception
*/
public void commit(TransactionStatus transactionStatus) throws Exception {
if (transactionStatus == null) {
throw new Exception("transactionStatus is null");
}
// 支持Redis与数据库事务同时提交
dataSourceTransactionManager.commit(transactionStatus);
}
/**
* 回滚事务
*
* @param transactionStatus
* @throws Exception
*/
public void rollback(TransactionStatus transactionStatus) throws Exception {
if (transactionStatus == null) {
throw new Exception("transactionStatus is null");
}
// 1.回滚数据库事务 redis事务和数据库的事务同时回滚
dataSourceTransactionManager.rollback(transactionStatus);
// // 2.回滚redis事务
// redisUtil.discard();
}
// 如果redis的值与数据库的值保持不一致话
}
(7)根据Token查询用户信息
使用token向redis中查询userId
@RestController
public class MemberServiceImpl extends BaseApiService<UserOutDTO> implements MemberService {
@Autowired
private UserMapper userMapper;
@Autowired
private GenerateToken generateToken;
@Override
public BaseResponse<UserOutDTO> existMobile(String mobile) {
// 1.验证参数
if (StringUtils.isEmpty(mobile)) {
return setResultError("手机号码不能为空!");
}
// 2.根据手机号码查询用户信息 单独定义code 表示是用户信息不存在把
UserDo userEntity = userMapper.existMobile(mobile);
if (userEntity == null) {
return setResultError(Constants.HTTP_RES_CODE_EXISTMOBILE_203, "用户信息不存在!");
}
// 3.将do转换成dto
return setResultSuccess(MeiteBeanUtils.doToDto(userEntity, UserOutDTO.class));
}
@Override
public BaseResponse<UserOutDTO> getInfo(String token) {
// 1.验证token参数
if (StringUtils.isEmpty(token)) {
return setResultError("token不能为空!");
}
// 2.使用token查询redis 中的userId
String redisUserId = generateToken.getToken(token);
if (StringUtils.isEmpty(redisUserId)) {
return setResultError("token已经失效或者token错误!");
}
// 3.使用userID查询 数据库用户信息
Long userId = TypeCastHelper.toLong(redisUserId);
UserDo userDo = userMapper.findByUserId(userId);
if (userDo == null) {
return setResultError("用户不存在!");
}
// 可重构,将代码放入在BaseApiService
return setResultSuccess(MeiteBeanUtils.doToDto(userDo, UserOutDTO.class));
}
// token存放在PC端 cookie token 存放在安卓 或者IOS端 存放在本地文件中
// 当前存在那些问题? 用户如果退出或者修改密码、忘记密码的情况 对token状态进行标识
// token 如何防止伪造 真正其实很难防御伪造 尽量实现在安全体系 xss 只能在一些某些业务模块上加上必须验证本人操作
}