BBS论坛项目相关-3:用户登陆模块
会话管理
HTTP是无状态,有会话的。无状态:在同一个连接中,两个执行成功的请求之间是没有关系的。使用HTTP的头部扩展,HTTP Cookie可以解决这个问题,使用cookie让http协议变为有状态。
Cookie
HTTP Cookie是服务器发送到用户浏览器并保存到本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器。
使用cookie让http协议变为有状态
Cookie默认存在内存里,关闭浏览器失效,设置生存时间后存在硬盘里。
存在客户端安全度不高,不适合存储密码,且很多请求中都会发送cookie,会增加数据量,对数据量有影响。
session
用于在服务器端记录客户端信息
数据存放在服务端更加安全,但也会增加服务端的内存压力
session称为会话信息,位于web服务器上,主要负责访问者与网站之间的交互,当访问浏览器请求http地址时,将传递到web服务器上并与访问信息进行匹配, 当关闭网站时就表示会话已经结束,网站无法访问该信息了,所以它无法保存永久数据,我们无法访问以及禁用网站。
session与cookie的区别
(1)Cookie以文本文件格式存储在浏览器中,而session存储在服务端它存储了限制数据量。
(2)cookie的存储限制了数据量,只允许4KB,而session是无限量的,且比cookie更安全
(3)我们可以轻松访问cookie值但是我们无法轻松访问会话值,因此它更安全
(4)设置cookie时间可以使cookie过期。但是使用session-destory(),我们将会销毁会话。cookie默认存在内存中,浏览器关闭则销毁,设置了生存时间后会存在硬盘里。
总结:如果我们需要经常登录一个站点时,最好用cookie来保存信息,要不然每次登陆都特别麻烦,如果对于需要安全性高的站点以及控制数据的能力时需要用会话效果更佳,当然我们也可以结合两者,使网站按照我们的想法进行运行。
分布式session
Nginx:负载均衡 分布式session无法跨域的问题
1 粘性session:同一个id分给同一个机器,但这样无法保证负载均衡
2 同步,将各个服务器的session同步,但这样服务器的解耦不够
3 共享session,单独拿一个服务器放session,这样如果服务器崩了就全部无法获得session
4 存在cookie或数据库 存入redis
Session的安全性:
Session 不安全的根本原因其实就是二点,第一个就是会话传递机制导致的(Cookie 和 URL参数)劫持问题,另外个就是会话固定的问题。
仅仅使用 Cookie 来存放 Session ID
Session的数据时保存在服务器端的,Session ID 可以使用 Cookie 和 URL 参数来传递,相对来说,Cookie 更安全( URL 暴露的频率更高),假如你不考虑 Cookie 被禁用的问题,你应该只使用 Cookie 来传递。
Cookie 和 Session 的过期时间保持一致。
Session 的存活时间是“变化的”,只要会话一初始化,Session 文件的修改时间就会更新,换句话说,设置该 GC 时间为 2 小时过期,但是只要用户间隔 2 小时一直保持会话更新(比如刷新页面),则 Session 一直会存在。 GC 到期后,由 PHP 来控制删除 Session 文件,但是并不是每次就会删除过期文件,所以不能依赖它(但是可以通过自定义 Session 管理器来实现这个机制)。 考虑到 Session GC 不可依赖,所以间接的可以设置 Cookie 对应的过期时间(session.cookie_lifetime 指令)等于 GC 时间,这样一到过期时间,由于 Cookie 失效了,等同于 Session 也失效了(虽然 Session 对应的文件并没有删除,假如攻击者知道 Session ID 还是会带来安全问题)。
生成验证码
Kaptcha:生成验证码
配置类:
@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;
}
}
生成验证码,并存入session
@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();
//os流输出png格式的image内容
ImageIO.write(image, "png", os);
} catch (IOException e) {
logger.error("响应验证码失败:" + e.getMessage());
}
}
用户登录
- 登陆
验证账号,密码,验证码
成功时生成登陆凭证,发放给客户端,失败时,跳转回登录页 - 退出
将登录凭证修改为失效状态
跳转回网站首页。
登陆凭证类:
public class LoginTicket {
private int id;
private int userId;
private String ticket;
private int status;//0-有效,1-无效
private Date expired;
}
设计一个登录凭证的类,在成功登陆后生成ticket作为访问凭证唯一标识并发送给服务器,这样可以通过凭证直接访问,作用类似于session。
service层
验证账号,密码等,验证通过后生成随机UUID作为登陆凭证。
public Map<String, Object> login(String username, String password, long 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;
}
controller层
验证验证码,从session中取出验证码,验证是否相等
检查账号,密码,如果不记住我,过期时间设置短一点,记住我则过期时间长
登陆有效则将其ticket放到cookie,否则返回错误信息。
@RequestMapping(path = "/login", method = RequestMethod.POST)
public String login(String username, String password, String code, boolean rememberme,
Model model, /*HttpSession session, */HttpServletResponse response,
@CookieValue("kaptchaOwner") String kaptchaOwner) {
// 检查验证码
String kaptcha = (String) session.getAttribute("kaptcha");
if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
model.addAttribute("codeMsg", "验证码不正确!");
return "/site/login";
}
// 检查账号,密码
int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = userService.login(username, password, expiredSeconds);
if (map.containsKey("ticket")) {
Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
cookie.setPath(contextPath);//设置cookie能被共享的范围
/*
此处的参数,是相对于应用服务器存放应用的文件夹的根目录而言的,
cookie.setPath("/");之后,可以在webapp文件夹下的所有应用共享cookie,
*/
cookie.setMaxAge(expiredSeconds);
response.addCookie(cookie);
return "redirect:/index";
} else {
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
return "/site/login";
}
}
退出登陆
service层
public void logout(String ticket) {
loginTicketMapper.updateStatus(ticket, 1);//修改状态值
}
controller层
@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket) {
userService.logout(ticket);
return "redirect:/login";
}
@cookieValue获得cookie中的参数
显示登录信息
拦截器:实现接口HandlerInterceptor
@Component
public class AlphaInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
// 在Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.debug("preHandle: " + handler.toString());
return true;
}
// 在Controller之后执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.debug("postHandle: " + handler.toString());
}
// 在TemplateEngine之后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
logger.debug("afterCompletion: " + handler.toString());
}
}
配置类:配置需要拦截哪些请求
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
// @Autowired
// private LoginRequiredInterceptor loginRequiredInterceptor;
@Autowired
private MessageInterceptor messageInterceptor;
@Autowired
private DataInterceptor dataInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg")//静态资源不拦截
.addPathPatterns("/register", "/login");//明确需要拦截的路径
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
// registry.addInterceptor(loginRequiredInterceptor)
// .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
registry.addInterceptor(messageInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
WebMvcConfigurer配置类其实是Spring内部的一种配置方式,采用JavaBean的形式来代替传统的xml配置文件形式进行针对框架个性化定制,可以自定义一些Handler,Interceptor,ViewResolver,MessageConverter。基于java-based方式的spring mvc配置,需要创建一个配置类并实现WebMvcConfigurer 接口;
拦截器应用
在请求开始时查询登陆用户
在本次请求中持有用户数据
在模板视图上显示用户数据
在请求结束时清理用户数据
cookie中的值较常使用,可以创建一个工具类用于获得cookie中的值
public class CookieUtil {
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中获取登陆凭证,查看是否有效,查询用户,为了后面可能会用到用户信息,需要在本次请求中保存持有用户,如在保存评论信息,需要知道用户信息,可以直接取出,而不用根据id查询,又要考虑线程并发,线程隔离,将用户存在ThreadLocal中。
preHandle使用拦截器,将用户信息存入当前线程ThreadLocal。PostHandle在模板引擎之前需要用用户信息,所以在模板引擎之前存在modelView里,便于之后调用。
@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);
// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
}
return true;
}
//模板引擎之前用
//在模板之前就把用户数据放在model里
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser", user);
}
}
//在模板引擎之后调用,清除threadlocal的数据
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
SecurityContextHolder.clearContext();
}
}
ThreadLocal:持有用户信息,用于代替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();
}
}
检查登陆状态
自定义注解:
元注解:@Target @Rentention @Document @Inherited
@Target:Target注解的作用是:描述注解的使用范围(即:被修饰的注解可以用在什么地方)
@Rentention:描述注解保留的时间范围(即:被描述的注解在它所修饰的类中可以被保留到何时) ,编译时有效或运行时有效,源文件有效
@Document:描述在使用 javadoc 工具为类生成帮助文档时是否要保留其注解信息。
@Inherited:使被它修饰的注解具有继承性(如果某个类使用了被@Inherited修饰的注解,则其子类将自动具有该注解)。
如何读取注解:
通过反射获取注解信息
Method.getDeclaredAnnotation()
Method.getAnnotation(Class annotationClass);
自定义注解:起一个标记作用
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
对于登陆以后才能访问的功能方法进行注解,标记
创建拦截器对请求拦截
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {//如果拦截到的是方法
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
if (loginRequired != null && hostHolder.getUser() == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false;//用户未登录就拦截,跳转到登录页面
}
}
return true;
}
}