一、基于 Session 实现登录
; (1) 发送短信验证码
① 手机号格式后端校验
手机号校验的正则表达式
public abstract class RegexPatterns {
public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
public static final String PASSWORD_REGEX = "^\\w{4,32}$";
public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";
}
校验工具类:
public class RegexUtils {
public static boolean isPhoneInvalid(String phone) {
return mismatch(phone, RegexPatterns.PHONE_REGEX);
}
public static boolean isEmailInvalid(String email) {
return mismatch(email, RegexPatterns.EMAIL_REGEX);
}
public static boolean isCodeInvalid(String code) {
return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
}
private static boolean mismatch(String str, String regex) {
if (StrUtil.isBlank(str)) {
return true;
}
return !str.matches(regex);
}
}
② 生成短信验证码
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.7.17version>
dependency>
🌼 hutool 工具的详细使用: doc.hutool.cn/pages/index…
@Override
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String code = RandomUtil.randomNumbers(6);
session.setAttribute("code", code);
log.info("向 {} 手机号发送了验证码:{}", phone, code);
return Result.ok("发送验证码成功");
}
(2) 短信验证码登录、注册
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
Result result = requestParamsValidate(loginForm, session);
if (!result.getSuccess()) {
return result;
}
String phone = loginForm.getPhone();
User user = query().eq("phone", phone).one();
if (user == null) {
Result saveResult = saveUserByPhone(phone);
if (saveResult.getSuccess()) {
user = (User) saveResult.getData();
} else {
return saveResult;
}
}
session.setAttribute("user", user);
return Result.ok("登录成功");
}
private Result saveUserByPhone(String phone) {
User newUser = new User();
newUser.setNickName("USER_" + RandomUtil.randomString(9));
newUser.setPhone(phone);
if (save(newUser)) {
return Result.ok(newUser);
}
return Result.fail("服务器忙, 用户保存到数据库失败");
}
private Result requestParamsValidate(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
Object cacheCode = session.getAttribute("code");
String paramCode = loginForm.getCode();
if (RegexUtils.isCodeInvalid(paramCode) || !paramCode.equals(cacheCode)) {
return Result.fail("验证码错误");
}
return Result.ok();
}
(3) 登录验证
🌿 根据 Cookie 中的 JSESSIONID 获取到 Session 🌿 然后从 Session 中获取到信息
🌿 登录校验需要在拦截器(Interceptor)中完成 🌿 SpringMVC 的 Interceptor 可以拦截 Controller,在请求到达 Controller 之前做一些事情
; ① 通过 SpringMVC 定义拦截器
- 实现(implements) HandlerInterceptor 接口
- 可覆盖该接口中的三个 *默认方法
🌼 preHandle:在 Controller 的处理方法之前调用(当该方法的返回值为 true 的时候 才执行 Controller 里面的内容)
🌱通常在 preHandle 中进行初始化、请求预处理等操作(可进行登录验证)
🌱 preHandle 返回 true 才会执行后面的调用。若返回 false,不会调用 Controller 处理方法、postHandle 和 afterCompletion
🌱当有多个拦截器时,preHandle 按照正序执行
🌼 postHandle:在 Controller 的处理方法之 后, DispatcherServlet 进行视图渲染之 前调用
🌱可在 postHandle 中进行请求后续加工处理操作
🌱当有多个拦截器时,postHandle 按照 逆序执行
🌼 afterCompletion:在 DispatcherServlet 进行视图渲染之后调用
🌱一般在这里进行 资源回收操作
🌱当有多个拦截器时,afterCompletion 按照 逆序执行
配置拦截器:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
② ThreadLocal
🌼 ThreadLocal 可以解释成 线程的局部变量 🌼一个 ThreadLocal 的变量只有当前自身线程可以访问,别的线程都访问不了,那么自然就避免了线程竞争 🌼ThreadLocal 提供了一种与众不同的线程安全方式,它不是在发生线程冲突时想办法解决冲突,而是彻底避免了冲突的发生
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user) {
tl.set(user);
}
public static UserDTO getUser() {
return tl.get();
}
public static void removeUser() {
tl.remove();
}
}
登录拦截器:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
if (null == user) {
response.setStatus(401);
return false;
}
UserDTO userDTO = user2UserDto((User) user);
UserHolder.saveUser(userDTO);
return true;
}
private UserDTO user2UserDto(User user) {
UserDTO userDTO = new UserDTO();
userDTO.setIcon(user.getIcon());
userDTO.setId(user.getId());
userDTO.setNickName(user.getNickName());
return userDTO;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
UserHolder.removeUser();
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
}
}
@GetMapping("/me")
public Result me() {
UserDTO userDto = UserHolder.getUser();
return Result.ok(userDto);
}
🌼
/me这个 Controller 执行完毕后,LoginInterceptor 的afterCompletion会被调用
(4) 集群 Session 不共享问题
; 二、基于 Redis 实现共享 session 登录
🎉 登录之后, 每次发起请求都要携带 token(用户登录凭证)
(1) 登录之后,缓存 token 到客户端
login() {
const {radio, phone, code} = this.form
if (!radio) {
this.$message.error("请先确认阅读用户协议!");
return
}
if (!phone || !code) {
this.$message.error("手机号和验证码不能为空!");
return
}
if (phone.length !== 11) {
this.$message.error("手机号格式错误!");
return
}
axios.post("/user/login", this.form)
.then(({data}) => {
if (data) {
sessionStorage.setItem("token", data);
}
location.href = "/info.html"
})
.catch(err => {
console.log(err)
this.$message.error(err)
})
},
(2) 每次请求都携带 token
let commonURL = "/api";
axios.defaults.baseURL = commonURL
axios.defaults.timeout = 2000
let token = sessionStorage.getItem("token")
axios.interceptors.request.use(
config => {
if (token) config.headers['authorization'] = token
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)
axios.interceptors.response.use(function (response) {
if (!response.data.success) {
return Promise.reject(response.data.errorMsg)
}
return response.data;
}, function (error) {
console.log(error)
if (error.response.status == 401) {
setTimeout(() => {
location.href = "/login.html"
}, 200);
return Promise.reject("请先登录");
}
return Promise.reject("服务器异常");
});
axios.defaults.paramsSerializer = function (params) {
let p = "";
Object.keys(params).forEach(k => {
if (params[k]) {
p = p + "&" + k + "=" + params[k]
}
})
return p;
}
🎶在 axios 的请求拦截器中配置 token,每次发起请求该请求会首先被拦截 🎶被拦截之后,往该请求的请求头中设置 token【key: authorization;value: token 值】
(3) 短信验证码
Redis 相关常量:
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:phone";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 36000L;
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,
code,
RedisConstants.LOGIN_CODE_TTL,
TimeUnit.MINUTES);
log.info("向 {} 手机号发送了验证码:{}", phone, code);
return Result.ok("发送验证码成功");
}
(4) 短信验证码登录、注册
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
Result result = requestParamsValidate(loginForm, session);
if (!result.getSuccess()) {
return result;
}
String phone = loginForm.getPhone();
User user = query().eq("phone", phone).one();
if (user == null) {
Result saveResult = saveUserByPhone(phone);
if (saveResult.getSuccess()) {
user = (User) saveResult.getData();
} else {
return saveResult;
}
}
String token = RedisConstants.LOGIN_USER_KEY + UUID.randomUUID().toString(true);
UserDTO userDTO = user2UserDto(user);
Map<String, Object> userDtoMap = BeanUtil.beanToMap(userDTO,
new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
stringRedisTemplate.opsForHash().putAll(token, userDtoMap);
stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
session.setAttribute("user", user);
return Result.ok(token);
}
private UserDTO user2UserDto(User user) {
UserDTO userDTO = new UserDTO();
userDTO.setIcon(user.getIcon());
userDTO.setId(user.getId());
userDTO.setNickName(user.getNickName());
return userDTO;
}
private Result saveUserByPhone(String phone) {
User newUser = new User();
newUser.setNickName("USER_" + RandomUtil.randomString(9));
newUser.setPhone(phone);
if (save(newUser)) {
return Result.ok(newUser);
}
return Result.fail("服务器忙, 用户保存到数据库失败");
}
private Result requestParamsValidate(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String redisCode = stringRedisTemplate.opsForValue()
.get(RedisConstants.LOGIN_CODE_KEY + phone);
String paramCode = loginForm.getCode();
if (RegexUtils.isCodeInvalid(paramCode) || !paramCode.equals(redisCode)) {
return Result.fail("验证码错误");
}
return Result.ok();
}
}
(5) 免登录
🎄 需要实现一个效果:用户只有在使用该应用,哪么该用户的登录有效期就延长七天 🎄 而不是到达七天就退出登录
🎄 在 LoginInterceptor 中,若用户有发请求,就把该用户的登录有些时间的缓存更新为七天
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
if (userMap.isEmpty()) {
response.setStatus(401);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
UserHolder.removeUser();
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
}
}
🎄 LoginInterceptor 拦截器并没有被 IoC 管理,所以不能在 LoginInterceptor 中使用
@Resource和@Autowired
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
}
}
(6) 刷新登录有效期
🎄 RefreshInterceptor 会拦截每一个请求,然后进行登录有效期刷新 🎄 上一节中的 LoginInterceptor 只有当访问需要登录校验的请求的时候才会刷新
public class RefreshLoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshLoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
UserHolder.removeUser();
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
}
}
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
);
registry.addInterceptor(new RefreshLoginInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(-1);
}
}