此设计思路主要适合:后端SpringBoot+SSM,前端:Vue
且,前端项目需要实现多种情况的登录(如大型电商平台:门户网站的普通用户登录与注册,后台管理系统的员工登录)。
多种登录情况:
1、数据库设计:
如后台管理的员工登录和普通用户的登录,后端代码只用一种方法实现。
设计一个loginInfo表,存放管理员表和普通用户表的登录信息。添加字段type,用于区分登录的是管理员还是用户。所有有关用户和管理员的查询都通过LoginInfo表查询。 但对管理员表和用户表的修改需要同步到LoginInfo表。(数据库设计:反范式)
2、完全舍弃cookie和session,用Redis代替
浏览器:存放token到localstorage
移动端:存放到文件
前端技术:axios前置拦截器、axios后置拦截器、Vue-router路由拦截器
后端技术:Redis、HandlerInterceptor
1、验证码的生成:校验(演示用简单校验)、生成验证码,将验证码存入Redis设置过期时间,将验证码返回前端。
@Service
public class VerificationCodeServiceImpl extends BaseServiceImpl<LoginInfo> implements IVerificationCodeService {
@Autowired
private RedisUtil redisUtil;
@Autowired
private LoginInfoMapper loginInfoMapper;
/**
* 验证码设置
*
* @return 验证码
*/
@Override
public AjaxResult getVerificationCode(Map<String,String> phone) {
String phoneNumber = phone.get("phone");
//验证手机号
if(StringUtils.hasLength(phoneNumber))
AjaxResult.result().setSuccess(false).setMessage("请输入手机号码!");
//验证用户是否已经注册
LoginInfo loginIfo=loginInfoMapper.queryByPhoneNumber(phoneNumber);
if (loginIfo!=null)
AjaxResult.result().setSuccess(false).setMessage("该手机号已经被注册!");
//查询redis是否有该手机号注册用户的验证码
String key = "register" + phoneNumber;
//value的值 (验证码:时间戳)
String value= (String) redisUtil.get(key);
//验证码
String code="";
//如果value为空,生成验证码
if(value==null){
//生成4位数验证码
code = StrUtils.getComplexRandomString(4);
}else {//如果value不为空,判断验证码是否过期
String time = value.split(":")[1];
if(System.currentTimeMillis()-Long.valueOf(time)<60*1000*1)
return AjaxResult.result().setSuccess(false).setMessage("请不要频繁发送");
else
code=value.split(":")[0];
}
//存放到redis
redisUtil.set(key,code+":"+System.currentTimeMillis(),3*60);
//向用户发送短信,此处可扩展短信技术,向手机发送验证码
//SmsUtil.sendSms(phoneNumber,"您本次注册的验证码为:"+code+"请在3分钟以内使用,请勿转告他人。");
System.out.println(redisUtil.get(key)+" -- "+redisUtil.getExpire(key));
return AjaxResult.result();
}
}
/**
* user用户注册
*
* @param userDto 用户注册信息
* @return 注册结果
*/
@Transactional
@Override
public AjaxResult register(UserDto userDto) {
//校验手机
if (!StringUtils.hasLength(userDto.getPhone()))
return AjaxResult.result().setSuccess(false).setMessage("手机号不能为空");
LoginInfo ofLoginInfo=loginInfoMapper.queryByPhoneNumber(userDto.getPhone());
if (ofLoginInfo!=null)
return AjaxResult.result().setSuccess(false).setMessage("该手机已被注册");
//校验短信验证码
if (!StringUtils.hasLength(userDto.getVerificationCode()))
return AjaxResult.result().setSuccess(false).setMessage("请输入验证码");
String value = (String)redisUtil.get("register" + userDto.getPhone());
if (value==null){
return AjaxResult.result().setSuccess(false).setMessage("验证码已过期,请重新发送");
}
else {
String code = value.split(":")[0];
if (!code.equals(userDto.getVerificationCode()))
return AjaxResult.result().setSuccess(false).setMessage("验证码错误");
}
//校验密码
if (!StringUtils.hasLength(userDto.getPassword()))
return AjaxResult.result().setSuccess(false).setMessage("请输入密码");
if(!userDto.getPassword().equals(userDto.getRePassword()))
return AjaxResult.result().setSuccess(false).setMessage("两次输入的密码不同");
//保存注册信息User和Login
LoginInfo loginInfo = userDto2LoginInfo(userDto);
loginInfoMapper.insert(loginInfo);
User user = loginInfo2User(loginInfo);
userMapper.insert(user);
return AjaxResult.result();
}
所有有关用户和管理员的查询都通过LoginInfo表查询。 但对员工表(t_employee)和用户表(t_user)的修改需要同步到LoginInfo表。(数据库设计:反范式)用户表和员工表都设置了外键loginInfo_id,关联到LoginInfo表。LoginInfo表新增数据的主键,作为用户表和员工表的外键。
import lombok.Data;
@Data
public class UserDto {
private String phone;
private String password;
private String rePassword;
private String verificationCode;
}
/**
* 将注册信息转换为LoginInfo实体类
*
* @param userDto 注册信息
* @return LoginInfo实体类
*/
private LoginInfo userDto2LoginInfo(UserDto userDto){
LoginInfo loginInfo = new LoginInfo();
//设置用户名,默认为手机号
loginInfo.setUsername(userDto.getPhone());
//设置电话
loginInfo.setPhone(userDto.getPhone());
//设置盐值
String salt = UUID.randomUUID().toString();
loginInfo.setSalt(salt);
//设置密码
String password = MD5Utils.encrypByMd5(salt + userDto.getPassword());
loginInfo.setPassword(password);
//0 代表管理员 1 用户
loginInfo.setType(1);
//0 不可以用 1 可用
loginInfo.setDisable(1);
return loginInfo;
}
/**
* 将loginInfo的信息转换为User实体类
*
* @param loginInfo loginInfo实体类
* @return user实体类
*/
public User loginInfo2User(LoginInfo loginInfo){
User user = new User();
//设置用户名,默认为手机号
user.setUsername(loginInfo.getUsername());
//邮箱
user.setEmail(loginInfo.getEmail());
//设置电话
user.setPhone(loginInfo.getPhone());
//设置盐值
user.setSalt(loginInfo.getSalt());
//设置密码
user.setPassword(loginInfo.getPassword());
//0 不可以用 1 可用
user.setState(loginInfo.getDisable());
//创建时间
user.setCreatetime(new Date());
//设置user对loginInfo的外键
user.setLoginInfo(loginInfo);
user.setAge(loginInfo.getAge());
user.setHeadimg(loginInfo.getHeadImg());
return user;
}
以前没有前后台分离,前后台页面都是在同一个服务器里面,不存在跨域问题。 用户访问服务器在服务器端产生session并存储。现在每一次请求页面,前端部署的服务器还要向后端部署的服务器发起请求,如果前端没有以cookie的方式携带jssionid到服务器。后台服务是没有办法保持会话的。 于是对于登录,完全舍弃传统的cookie和session。通过后UUID生成一个token作为key,将登录用户的信息(去除敏感信息)作为value,存放入Redis,并设置过期时间(一般为30分钟)。同时将token和登录信息发送给前台。
<script type="text/javascript">
new Vue({
el: "#loginFormDiv",
data: {
loginForm: {
username: '',
password: '',
state: 1,
}
},
methods: {
login() {
var loginParams = {
username: this.loginForm.username
, password: this.loginForm.password
, state: this.loginForm.state
};
this.$http.post("/login/account", loginParams).then(result => {
let {success, object, message} = result.data;
if (success) {
localStorage.setItem("uToken", object.token);
localStorage.setItem("uUser", JSON.stringify(object.user));
location.href = "index.html";
} else {
alert(message)
}
}).catch(result => {
alert("网络繁忙!")
})
}
},
})
</script>
在HTML5中,新加入了一个localStorage特性,这个特性主要是用来作为本地存储来使用的。
解决了cookie存储空间不足的问题,localStorage中一般浏览器支持的是5M大小。
这个在不同的浏览器中localStorage会有所不同。
localStorage:持久化存储。只要不删除,在当前浏览器永远有效
前台收到token和登录信息,存储到localStorage中。
/**
* 用户名登录
*
* @param loginDto 登录信息
* @return 登录结果
*/
@Override
public AjaxResult login(LoginDto loginDto) {
//校验
if(StringUtils.isEmpty(loginDto.getUsername()))
return AjaxResult.result().setSuccess(false).setMessage("用户名不能为空");
if (StringUtils.isEmpty(loginDto.getPassword()))
return AjaxResult.result().setSuccess(false).setMessage("密码不能为空");
//校验用户名是否正确username,phone,email
LoginInfo loginInfo=loginInfoMapper.queryByLoginDto(loginDto);
if(loginInfo==null)
return AjaxResult.result().setSuccess(false).setMessage("账号或密码错误");
//校验用户是否被禁用
if (loginInfo.getDisable()!=1)
return AjaxResult.result().setSuccess(false).setMessage("用户被禁用");
//校验密码
String salt = loginInfo.getSalt();
String password = MD5Utils.encrypByMd5(salt + loginDto.getPassword());
if (!password.equals(loginInfo.getPassword()))
return AjaxResult.result().setSuccess(false).setMessage("账号或密码错误");
//token代替session
String token = UUID.randomUUID().toString();
redisUtil.set(token,loginInfo,60*30);
//返回前台数据,存放发到浏览器
Map<String,Object> map=new HashMap<>();
map.put("token",token);
//这里返回用户信息后,不用再单独发请求获取用户信息
loginInfo.setPassword("");//安全起见,不返回
loginInfo.setSalt("");//安全起见,不返回
map.put("user",loginInfo);
return AjaxResult.result().setMessage("登录成功").setObject(map);
}
//************************** axios前置(request)拦截配置 *******************************
//设置请求头信息,登录后,每次请求等能过携带token
axios.interceptors.request.use(config => {
let token = localStorage.getItem("uToken")
if (token) {
config.headers["token"] = token;
}
return config;
}, error => {
Promise.reject(error);
});
设置请求头信息,登录后,每次请求等能过携带token,同时后台设置拦截器,拦截所有请求。获取请求头的token,检查本次请求的token在Redis是否还存在或者已过期。若没有过期,则重新设置过期时间(相当于又变成30分钟后过期)。若没有Redis查找不到,则说明没有登录,或者登录过期。拦截器不放行该请求请求,同时给通知前端该请求被拦截。
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private RedisUtil redisUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头里面的token的值
String token = request.getHeader("token");
if (!StringUtils.isEmpty(token)){
//验证Redis是是否存在此
Object object = redisUtil.get("token");
//判断Redis的token是否过期
if(object!=null) {
LoginInfo loginInfo = (LoginInfo) object;
//重新更新时间,放在token过期
redisUtil.set(token,loginInfo,30*60);
//@Todo 权限校验
return true;
}
}
//校验没有通过 返回一个前台能够识别错误 {"success":false,"message":'noLogin'}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.print("{\"success\":false,\"message\":\"noLogin\"}");
out.flush();
out.close();
return false;
}
}
后端配置拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
/*registry.addInterceptor(loginInterceptor)
//放行登录
.excludePathPatterns("/login/**")
//放行dfs文件上传管理系统
.excludePathPatterns("/dfs")
//放行注册验证
.excludePathPatterns("/verification/**")
//放行注销
.excludePathPatterns("/logout")
//放行用户注册
.excludePathPatterns("/user/phone/register");
}
}
后置拦截器主要获取,后端所拦截到的请求,返回给前端的信息。信息匹配,则删除localStorage的用户信息,已经token。
//************************** axios后置(response)拦截配置 *****************************
// out.print("{\"success\":false,\"message\":\"noLogin\"}");
axios.interceptors.response.use(config => {
if (!config.data.success && "noLogin" === config.data.message) {
localStorage.removeItem("uToken");
localStorage.removeItem("uUser");
window.location.href = "index.html"
}
return config;
}, error => {
Promise.reject(error)
});
此拦截器的作用是,对于需要登录才能访问页面,判断localStorage中是否有uUser就行
//对于需要登录才能访问页面,判断localStorage中是否有uUser就OK
var url = location.href;
if (url.indexOf("login.html") != -1 || url.indexOf("register.html") != -1
|| url.indexOf("bindPage.html") != -1 || url.indexOf("redirectPage.html") != -1) {
} else {
var user = localStorage.getItem("uUser");
if (!user) //没有用户,没有登录,跳转到登录页面
location.href = "/login.html"
}