目录
3.1 请求开始获得 ticket,利用 ticket 查找对应的 user
1.生成验证码
使用 kaptcha 工具:
- 导入 jar 包
- 编写 Kaptcha 配置类
- 生成随机字符、生成图片
pom.xml:
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
新建 config 包,新建 KaptchaConfig 配置类:
- 配置类需要加注解 @Configuration
- 使用 @Bean 注解 :实例化接口
- kaptcha 核心对象是接口
package com.example.demo.config;
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "100");
properties.setProperty("kaptcha.image.height", "40");
properties.setProperty("kaptcha.textproducer.font.size", "32");
properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
properties.setProperty("kaptcha.textproducer.char.length", "4");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}
}
在 controller 包下的 LoginController 类中添加一个生成验证码的方法:
- 返回 void:向浏览器输出一个特殊东西,是一张图片
@Controller
public class LoginController {
//日志
private static final Logger logger =LoggerFactory.getLogger(LoginController.class);
@Autowired
private Producer kaptchaProducer;
//生成验证码的方法
@RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
public void getKaptcha(HttpServletResponse response, HttpSession session) {
// 生成验证码
String text = kaptchaProducer.createText();
BufferedImage image = kaptchaProducer.createImage(text);
// 将验证码存入session
session.setAttribute("kaptcha", text);
// 将图片输出给浏览器
response.setContentType("image/png");
try {
OutputStream os = response.getOutputStream();
ImageIO.write(image, "png", os);
} catch (IOException e) {
logger.error("响应验证码失败:" + e.getMessage());
}
}
}
2.开发登录、退出功能
- 访问登录页面:点击顶部区域内的链接,打开登录页面
- 登录:验证账号、密码、验证码;成功时,生成登录凭证,发放给客户端;失败时,跳转回登录页
- 退出:将登录凭证修改为失效状态;跳转至网站首页
登录凭证表:
2.1 开发数据访问层
在 entity 包下创建 LoginTicket 实体类,用来封装上述数据
package com.example.demo.entity;
import java.util.Date;
public class LoginTicket {
private int id;
private int userId;
private String ticket;//字符串
private int status;//状态
private Date expired;//到期期
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getUserId() {
return userId;
}
public void setUserId(int userId) {
this.userId = userId;
}
public String getTicket() {
return ticket;
}
public void setTicket(String ticket) {
this.ticket = ticket;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public Date getExpired() {
return expired;
}
public void setExpired(Date expired) {
this.expired = expired;
}
@Override
public String toString() {
return "LoginTicket{" +
"id=" + id +
", userId=" + userId +
", ticket='" + ticket + '\'' +
", status=" + status +
", expired=" + expired +
'}';
}
}
接下来实现数据访问逻辑,在 dao 包下新建 LoginTicketMapper接口:
- 添加注解 @Mapper,表明是数据访问对象需要容器管理
- 业务方法:增加数据、选择凭证、修改状态
- 在 Mapper 接口中添加注解实现对应的SQL
package com.example.demo.dao;
import com.example.demo.entity.LoginTicket;
import org.apache.ibatis.annotations.*;
@Mapper
public interface LoginTicketMapper {
@Insert({
"insert into login_ticket(user_id,ticket,status,expired) ",
"values(#{userId},#{ticket},#{status},#{expired})"
})
//自动生成主键
@Options(useGeneratedKeys = true, keyProperty = "id")
//增加
int insertLoginTicket(LoginTicket loginTicket);
@Select({
"select id,user_id,ticket,status,expired ",
"from login_ticket where ticket=#{ticket}"
})
//查询:根据 ticket 进行查询
LoginTicket selectByTicket(String ticket);
@Update({
//动态 sql:添加 <script> 标签,然后使用 if 标签
//xml 中使用 if 标签,不需要加 <script> 标签
"<script>",
"update login_ticket set status=#{status} where ticket=#{ticket} ",
"<if test=\"ticket!=null\"> ",
"and 1=1 ",
"</if>",
"</script>"
})
//修改
int updateStatus(String ticket, int status);
}
需要注意的是,sql 语句一定要写正确,因为没有提示,接下来我们可以做一个测试:
插入测试:
public class MailTests {
@Autowired
private LoginTicketMapper loginTicketMapper;
@Test
public void testInsertLoginTicket() {
LoginTicket loginTicket = new LoginTicket();
loginTicket.setUserId(101);
loginTicket.setTicket("abc");
loginTicket.setStatus(0);
loginTicket.setExpired(new Date(System.currentTimeMillis() + 1000 * 60 * 10));
loginTicketMapper.insertLoginTicket(loginTicket);
}
}
查询修改测试:
//查询凭证、修改状态
@Test
public void testSelectLoginTicket() {
LoginTicket loginTicket = loginTicketMapper.selectByTicket("abc");
System.out.println(loginTicket);
loginTicketMapper.updateStatus("abc", 1);
loginTicket = loginTicketMapper.selectByTicket("abc");
System.out.println(loginTicket);
}
2.2 开发业务层:实现登录功能
在 service 包下的 UserService 中新增代码:
- 注入 LoginTicketMapper
- 实现登录功能:成功、失败、不存在等等情况,返回数据的情况很多,可以使用 map 封装多种返回结果
- 登录需要传入用户、密码、凭证有限时间
- 处理空值操作(用户、密码)
- 验证账号是否注册:使用输入的 username 去库里查询是否存在
- 验证账号是否处于激活状态
- 验证密码是否正确
- 库里的密码是加密过的,判断密码是否相同,需要给用户的密码也进行加密
- 如果上述验证都没有问题,则登录成功,生成登录凭证
@Service
public class UserService implements CommunityConstant {
@Autowired
private LoginTicketMapper loginTicketMapper;
/**
* 实现登录功能
*/
//实现登录功能:成功、失败、不存在等等情况,返回数据的情况很多,可以使用 map 封装多种返回结果
//登录需要传入用户、密码、凭证有限时间
public Map<String, Object> login(String username, String password, int expiredSeconds) {
Map<String, Object> map = new HashMap<>();
// 空值处理
if (StringUtils.isBlank(username)) {
map.put("usernameMsg", "账号不能为空!");
return map;
}
if (StringUtils.isBlank(password)) {
map.put("passwordMsg", "密码不能为空!");
return map;
}
// 验证账号
User user = userMapper.selectByName(username);
if (user == null) {
map.put("usernameMsg", "该账号不存在!");
return map;
}
// 验证状态
if (user.getStatus() == 0) {
map.put("usernameMsg", "该账号未激活!");
return map;
}
// 验证密码
password = CommunityUtil.md5(password + user.getSalt());//加密后的密码
if (!user.getPassword().equals(password)) {
map.put("passwordMsg", "密码不正确!");
return map;
}
// 生成登录凭证
LoginTicket loginTicket = new LoginTicket();//创建实体往库里存
loginTicket.setUserId(user.getId());
loginTicket.setTicket(CommunityUtil.generateUUID());//生成不重复随机字符串
loginTicket.setStatus(0);
loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
loginTicketMapper.insertLoginTicket(loginTicket);
map.put("ticket", loginTicket.getTicket());
return map;
}
}
2.3 编写表线程逻辑
在 controller 中写方法处理页面请求,登录页面传入表单(账号、密码、验证码),收到这三个信息提交给 UserService 处理,成功时,发放给客户端;失败时,跳转回登录页,在 controller 包下的 LoginController 中新增登陆方法:
- 首先添加访问路径:处理表单提交的数据使用 POST 请求
- 表单中传入 用户、密码、验证码、记住我(boolean 类型)、Model(返回数据)、HttpSession(页面传入验证码和之前的验证码进行对比)、 HttpServletResponse (登录成功,要把 ticket 发送给客户端使用 cookie 保存,创建 cookie 使用 HttpServletResponse 对象)
- 首先判断验证码是否正确
- 判断账号密码是否正确:没有勾选记住我,存入库中的时间比较短;勾选记住我,存入库中的时间比较长:定义两个常量放入 CommunityConstant 接口中
-
package com.nowcoder.community.util; public interface CommunityConstant { /** * 默认状态的登录凭证的超时时间 */ int DEFAULT_EXPIRED_SECONDS = 3600 * 12; /** * 记住状态的登录凭证超时时间 */ int REMEMBER_EXPIRED_SECONDS = 3600 * 24 * 100; }
- 如果勾选记住我,使用记住状态时间;如果不勾选,则使用默认的
- 如果登陆成功(判断 ticket),取出 ticket 发送 cookie 给客户端,重定向首页
- 如果登录失败,返回登陆页面
@Controller
public class LoginController {
//注入整个项目的参数路径
@Value("${server.servlet.context-path}")
private String contextPath;
@RequestMapping(path = "/login", method = RequestMethod.POST)
//表单中传入 用户、密码、验证码、记住我(boolean 类型)、Model(返回数据)、HttpSession(页面传入验证码和之前的验证码进行对比)
// 、 HttpServletResponse (登录成功,要把 ticket 发送给客户端使用 cookie 保存,创建 cookie 使用 HttpServletResponse 对象)
public String login(String username, String password, String code, boolean remember, Model model
, HttpSession session, HttpServletResponse response) {
//检查验证码
String kaptcha = (String) session.getAttribute("kaptcha");
if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
model.addAttribute("codeMsg", "验证码不正确");
return "/site/login";
}
//检查账号、密码:判断账号密码是否正确:没有勾选记住我,存入库中的时间比较短;勾选记住我,存入库中的时间比较长
// 定义两个常量放入 CommunityConstant 接口中:如果勾选记住我,使用记住状态时间;如果不勾选,则使用默认的
int expiredSeconds = remember ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = userService.login(username, password, expiredSeconds);
//成功:取出 ticket 发送 cookie 给客户端,重定向首页
if (map.containsKey("ticket")) {
Cookie cookie = new Cookie("ticket", map.get("ticket").toString());//map 中拿到的是对象需要转化为字符串
cookie.setPath(contextPath);//有效路径:整个项目但是不要写死,写参数即可
cookie.setMaxAge(expiredSeconds);//设置 cookie 有效时间
response.addCookie(cookie);//把 cookie 发送到页面
return "redirect:/index";
} else { //如果登录失败,返回登陆页面
//把错误的消息返回给页面
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
return "/site/login";
}
}
}
最后进行页面处理
2.4 退出
将登陆凭证修改为失效状态(在数据访问层已经实现),跳转到网站首页
在 service 包下 userService 中新增退出业务方法:
- 退出的时候把凭证传入,根据凭证找到用户进行退出,最后改变凭证状态
//退出业务方法
public void logout(String ticket) {
loginTicketMapper.updateStatus(ticket, 1);
}
在 controller 包下 LoginController 中添加一个处理页面的请求:
- 退出只是一个普通请求,不需要传表单,用 GET 请求即可
- 使用注解 @CookieValue 要求 spring mvc 注入 ticket:得到浏览器存的 cookie
- 最后重定向登陆页面
//退出业务方法
@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket) {
userService.logout(ticket);
return "redirect:/login";//默认 GET 请求
}
在前端配置 退出登录 链接:
<a class="dropdown-item text-center" th:href="@{/logout}">退出登录</a>
3.显示登录信息
- 使用拦截器:在请求开始查询登录用户、在本次请求中持有用户数据、在模板视图上显示用户数据、在请求结束时清理用户数据
3.1 请求开始获得 ticket,利用 ticket 查找对应的 user
这个过程是每次访问都需要的操作,我们可以使用一个拦截器:在 controller 包下创建一个 interceptor 包,在创建 LoginTicketInterceptor 类,实现 preHandle(执行具体方法之前的预处理)方法:
- 添加注解 @Component,并且实现 HandlerInterceptor 接口
- 实现 preHandle(执行具体方法之前的预处理)方法:在请求开始获得 ticket,利用 ticket 查找对应的 user
-
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { }
- 从 request 中取cookie,首先封装 cookie,在 util 包下新建 CookieUtil 类:
-
package com.example.demo.util; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; public class CookieUtil { //返回 cookie 中的值 public static String getValue(HttpServletRequest request, String name) { if (request == null || name == null) { throw new IllegalArgumentException("参数为空!"); } Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals(name)) { return cookie.getValue(); } } } return null; } }
- 通过 cookie 得到 ticket:判断 ticket 是否为空
- 当 ticket 不等于空,表示可以登录,登录就需要查询 ticket,调用 service,在 Userservice 类中添加 查询凭证代码
-
//添加 查询凭证代码 public LoginTicket findLoginTicket(String ticket) { return loginTicketMapper.selectByTicket(ticket); }
- 当 ticket 不等于空,查询 ticket,此时需要注入 UserService ,进行查询凭证
- 检查凭证是否有效:凭证不为空,并且状态是0,并且超时时间晚于当前时间才有效,之后根据凭证查询用户
- 拿到用户在 模板或者 Controller 处理业务的时候调用也可能在后面的过程中随时随地使用,因此需要在本次请求中持有用户
- 存储用户:创建一个公共类放在容器中是不可以的,因为浏览器访问服务器是多对一的方式,一个服务器能处理多个请求(并发),每个浏览器访问服务器,服务器会创建一个独立的线程解决请求,因此服务器在处理请求的时候是一个多线程环境,因此在存储线程的时候需要考虑多线程的问题 那么
- 存储并且使并发没有问题需要考虑线程隔离问题,每个线程单独存一份,不互相干扰,因此使用 ThreadLocal
- 将 user 存入 ThreadLocal 中,封装一下,其他地方可能会用到
- 在 util 包下新建 HostHolder 类:添加注解 @Compent (相当于容器的作用),持有用户信息,用于代替 session 对象;首先初始化 ThreadLocal(提供了 set 和 get 方法),添加 set 方法可以使外界传入 User,并且存入 ThreadLocal 中;添加 get 方法从 ThreadLocal 中取 User;最后添加一个清理的方法(请求结束的时候,把 ThreadLocal 中的 User 清理,只存不清不太好)
-
package com.example.demo.util; import com.example.demo.entity.User; import org.springframework.stereotype.Component; /** * 持有用户信息,用于代替session对象. */ @Component public class HostHolder { private ThreadLocal<User> users = new ThreadLocal<>(); public void setUser(User user) { users.set(user); } public User getUser() { return users.get(); } public void clear() { users.remove(); } }
- 注入 HostHolder,将用户暂存在 HostHolder 中
package com.example.demo.controller.interceptor;
import com.example.demo.entity.LoginTicket;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import com.example.demo.util.CookieUtil;
import com.example.demo.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response
, Object handler) throws Exception {
//从 cookie 中获取凭证
String ticket = CookieUtil.getValue(request,"ticket");
if (ticket != null) {
//查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
//检查凭证是否有效:凭证不为空,并且状态是0,并且超时时间晚于当前时间才有效
if (ticket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
}
}
return true;
}
}
以上 preHandle 方法已经完成:在请求开始之初,通过凭证找到用户,并且把用户暂存到 hostHolder(线程对应的对象中)
3.2 在模板引擎之前将 User 存储到 Model中
在 LoginTicketInterceptor 类中重写 postHandle 方法(在模板之前调用):
- 从 hostHolder 中得到当前线程持有的 User
- 判断 User 是否为空,如果不为空并且 ModelAndView(最终结果要放在这里) 也不为空,存入登录用户
//重写 postHandle 方法(在模板之前调用):
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler
, ModelAndView modelAndView) throws Exception {
//从 hostHolder 中得到当前线程持有的 User
User user = hostHolder.getUser();
//判断 User 是否为空,如果不为空并且 ModelAndView(最终结果要放在这里) 也不为空,存入登录用户
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser", user);//存入登录用户,值就是用户对象
}
}
上述方法执行完成之后,模板就开始执行的时候,Model 中已经有 User 对象了,就可以直接使用;
最后还需要清理 hostHolder 中的 User(在整个请求结束之后),重写 afterCompletion 方法
//最后还需要清理 hostHolder 中的 User(在整个请求结束之后),重写 afterCompletion 方法
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
hostHolder.clear();
}
3.3 配置拦截器
在 config 包下新建 WebMvcConfig 类:
- 添加注解 @Configuration,表示配置类
- 实现 WebMvcConfigurer 接口
- 注入拦截器 LoginTicketInterceptor
- 排除全部静态资源
package com.example.demo.config;
import com.example.demo.controller.interceptor.LoginTicketInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
处理首页逻辑功能: