0.准备工作
0.0 数据库设计
- tb_user 用户表。用来存储用户信息
- tb_role 角色表。用来存储角色信息
- tb_permission 权限表。用来存储权限信息。
- tb_user_role 用户角色关系表。用来存储用户和角色之间的关系,表示哪些用户属于哪些角色。
- tb_role_permission 角色权限关系表。用来存储角色和权限之间的关系,表示角色所具有哪些权限。
关于数据库表创建sql在码云仓库上有,地址在文章末尾有给出。
0.1 项目初始化
当前使用的技术是SpringBoot+Mybatis:
依赖pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>example</groupId>
<artifactId>hello-security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>hello-security</name>
<description>Hello Security</description>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MyBatis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
工程目录:
application.properties:
这里的mybatis.mapper-locations各位在写自己项目的时候要特别注意一下,classpath可以理解为项目编译后,classes目录:
我的mapper目录是直接放在resource目录下的,所以我这里是classpath:mapper/*.xml
## application port
server.port=8081
## application name
spring.application.name=spring-security-demo
## database configuration
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security_db01?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
## mybatis configuration
mybatis.mapper-locations=classpath:mapper/*.xml
Controller:
package example.hellosecurity.user.controller;
import example.hellosecurity.user.entity.MyUser;
import example.hellosecurity.user.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
UserService userService;
@PostMapping("/login")
public Boolean login(@RequestBody MyUser user){
Boolean haveThisUser = userService.isHaveThisUser(user);
return haveThisUser;
}
}
Service:
package example.hellosecurity.user.service;
import example.hellosecurity.user.entity.MyUser;
public interface UserService {
/**
* 判断是否存在此用户
* @param user
* @return
*/
Boolean isHaveThisUser(MyUser user);
}
ServiceImpl:
package example.hellosecurity.user.service.impl;
import example.hellosecurity.user.dao.UserMapper;
import example.hellosecurity.user.entity.MyUser;
import example.hellosecurity.user.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
public class UserServiceImpl implements UserService {
@Autowired
UserMapper userMapper;
/**
* 判断是否存在此用户
* @param user
* @return
*/
@Override
public Boolean isHaveThisUser(MyUser user) {
Boolean result = false;
MyUser userByUserName = userMapper.getUserByUserName(user.getUserName());
if (!Objects.isNull(userByUserName)) {
return true;
}
return result;
}
}
Mapper:
package example.hellosecurity.user.dao;
import example.hellosecurity.user.entity.MyUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper {
/**
* 根据用户名获取用户信息
* @param userName
* @return
*/
MyUser getUserByUserName(String userName);
}
MyUser用户实体类:
package example.hellosecurity.user.entity;
import lombok.Data;
@Data
public class MyUser {
/**
* 用户id
*/
private Integer userId;
/**
* 用户名
*/
private String userName;
/**
* 密码
*/
private String userPassword;
}
UserMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="example.hellosecurity.user.dao.UserMapper">
<select id="getUserByUserName" resultType="example.hellosecurity.user.entity.MyUser">
select t.user_id as userId,
t.user_name as userName,
t.user_password as userPassword
from tb_user t
where t.user_name = #{userName}
</select>
</mapper>
登录页面login.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Page</title>
<script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.0.js"></script>
<script type="application/javascript">
$(document).ready(function(){
$("#loginButton").click(function () {
$.ajax({
async: false,
type: 'post',
url: '/user/login',
contentType: 'application/json',
data: JSON.stringify({
"userName":$("#name").val(),
"userPassword":$("#password").val()
}),
success:function (data) {
if(data){
console.log("i am come in");
//跳转登录成功页面
window.location.href="http://localhost:8081/html/loginSuccess.html";
}
if(!data){
//跳转登录失败页面
window.location.href="http://localhost:8081/html/loginFail.html";
}
},
error:function(e){
window.location.href="http://localhost:8081/html/loginFail.html";
}
});
});
});
</script>
</head>
<body>
<h1>登录页面</h1>
<input id="name" type="text" value="用户名"/><br/>
<input id="password" type="password" value="密码"/><br/>
<input id="loginButton" type="button" value="登录"/>
</body>
</html>
登录成功页面loginSuccess.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Page</title>
<script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.0.js"></script>
</head>
<body>
<h1>登录成功!</h1>
</body>
</html>
登录失败页面loginFail.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Page</title>
<script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.0.js"></script>
</head>
<body>
<h1>登录失败!</h1>
</body>
</html>
目前基础项目已经搭建好了,关于验证用户只是做了一个简单的验证:
登录页面:
登录成功页面:
登录失败页面:
目前是可以根据输入的用户名是否正确来判断跳转登录成功页面还是跳转登录失败页面,但是有没有发现一个问题,就是我如果单独访问登录成功页面或者登录失败页面也是可以访问的,此时是不需要进行用户身份校验的,这明显不符合我们实际的一个业务场景。
1.使用Cookie和Session实现登录功能
1.1 仅仅使用Cookie进行控制
在前面的基础上,新加一个方法用来模拟获取资源:
@GetMapping("/getResource")
public String getResource(HttpServletRequest request, HttpServletResponse response){
//获取Cookie
Cookie[] cookies = request.getCookies();
if(!Objects.isNull(cookies) && 0 != cookies.length){
for(Cookie cookie : cookies){
if("adminCookie".equals(cookie.getName())){
return "money";
}
}
}
return "needAuthenticatioin";
}
在这里我们加了Cookie控制,只有request中携带Cookie的我们才进行真实资源的返回。而我们Cookie是放在添加成功,也就是通过校验之后才进行Cookie的添加:
@PostMapping("/login")
public Boolean login(@RequestBody MyUser user,HttpServletResponse response){
Boolean haveThisUser = userService.isHaveThisUser(user);
if(haveThisUser){
//设置Cookie
Cookie cookie = new Cookie("adminCookie", "admin");
//设置Cookie过期时间为两分钟
cookie.setMaxAge(60*2);
//设置根目录下的所有目录都可以共享Cookie
cookie.setPath("/");
response.addCookie(cookie);
}
return haveThisUser;
}
没有添加Cookie前:
添加Cookie后:
清空浏览器Cookie之后也是获取不到“真实资源money”的,不信你去试试。-<-
可以通过抓包来查看请求头中的Cookie:
1.2 使用Session进行会话管理
在登录成功之后进行session的设置:
@PostMapping("/login")
public Boolean login(@RequestBody MyUser user,HttpServletResponse response,HttpServletRequest request){
Boolean haveThisUser = userService.isHaveThisUser(user);
if(haveThisUser){
//登录成功设置session
HttpSession session = request.getSession();
session.setAttribute("loginName","admin");
}
return haveThisUser;
}
在获取资源的时候进行session的校验:
@GetMapping("/getResource")
public String getResource(HttpServletRequest request, HttpServletResponse response){
//获取Cookie
Cookie[] cookies = request.getCookies();
if(!Objects.isNull(cookies) && 0 != cookies.length){
//获取session对象
HttpSession session = request.getSession();
//取出会话数据
String loginName = (String)session.getAttribute("loginName");
if(!"".equals(loginName) && null != loginName && "admin".equals(loginName)){
//如果session中有响应的信息,则刷新session
session.setAttribute("admin",loginName);
return "money";
}
}
return "needAuthenticatioin";
}
可以看到,尽管我们没有手动去创建Cookie,但是随着我们登录成功,校验通过设置session之后,在请求头一栏中还是会有Cookie的。
这里说明一下,对于没有手动设置session过期时间的,在浏览器关闭之后,这个session也默认会失效(由于session依赖JSESSIONID的Cookie,而Cookie JSESSIONID的过期时间默认为-1,只要关闭浏览器,一次会话结束之后,session就会失效),必须重新验证才能访问资源。
1.3 Cookie和Session的小结
Cookie:
Cookie是用于在客户端保存用户信息的一种机制,Cookie的创建是由服务端进行创建,通过响应流返回到客户端,是由客户端进行保存。在前面的demo中可以知道,Cookie可以用来处理用户的身份校验。
Cookie分为内存Cookie和持久化Cookie,也是由服务端进行控制,内存Cookie在浏览器关闭之后就会进行清空,而持久化Cookie可以保存到本地,并有一定的有效期。
在HTTP中,Cookie是明文传递的。
Cookie的大小限制为4KB左右,对于复杂的存储需求是不够用的。
Cookie不能直接存储java对象,只能保存ASCLL字符串。
Session
什么是session,session用来表示客户端和服务端的一次会话,会话可以是连续的,也可以不是连续的。
在之前演示的demo中,很容易看到在请求头当中有一个名称叫做JSSESSIONID的Cookie。首先说一下sessionId,sessionId是一个会话的key,浏览器第一次访问服务器会在服务端生成一个session,有一个sessionId和它对应。tomcat生成的sessionId叫做JSSESSIONID。
那么sessionId是在什么时候创建的呢?首先说明,不同语言对于sessionId的创建有不同的实现,这里是基于Java实现的。在java中通过调用HttpServletRequest的getSession方法(使用true作为参数)创建session。在创建session的同时,服务器会该Session生成唯一的sessionId(tomcat的ManagerBase类提供创建sessionId的方法,随机数+时间+jvmid),这个sessionId会在随后的请求中被用来重新获得已经创建的session。之后我们就可以将需要的信息存储在session中,当然这部分数据是存储在服务端中,发送到客户端的只有sessionId。当客户端再次发送请求的时候,会将这个sessionId带上,服务器接收到相应请求之后就会根据sessionId找到相应的session,然后再次使用
关于Session的删除方式:超时;程序调用HttpSession.invalidate();程序关闭;
2.SpringSecurity实现登录功能
在上述Cookie+Session的demo中,仅仅实现了登录也就是“验证”功能,对于“授权”还没实现。
接下来会使用SpringSecurity进行登录和授权功能的实现。
2.1 什么是SpringSecurity
SpringSecurity是一款安全框架,主要也是做两件事,认证和授权。SpringSecurity实际上是作为一组过滤器链来对我们的系统进行支持和保护。如图:
请求在到达服务端前,必须通过SpringSecurity的层层校验才能正确访问服务端资源。
2.2 项目引入SpringSecurity
引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
引入依赖之后,系统会默认开启SpringSecurity的安全防护,所以 如果想要访问系统资源首先就必须通过SpringSecurity自带的登录验证:
在引入SpringSecurity之后,必须先经过SpringSecurity的验证,才能访问到我们的登录页面。 用户名是user,密码则在控制台可以看到:
2.3 SpringSecurity认证工作流程
SpringSecurity首先会到FilterChainProxy代理过滤器链中进行过滤器链的获取和生成,其中,UsernamePasswordAuthenticationFilter负责处理与认证相关的操作。
2.4 自定义登录认证实现
实际上使用安全框架和不适用安全框架要做的事情是一样的,就是取出用户信息,然后做密码比对,成功则通过,不成功则报错。
在UsernamePasswordAuthenticationFilter中,认证实际上是调用AuthenticationManager的authenticate方法进行校验的。
在实际开发中,SpringSecurity默认的验证机制是不满足我们的使用的,不可能每次请求都要登录一次SpringSecurity自带的登录框。所以我们需要在框架的基础上,自定义我们的认证逻辑。
首先进行配置类编写:
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceImpl userService;
/**
* 密码组件
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception{
return super.authenticationManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭csrf和frameOptions
http.csrf().disable();
http.headers().frameOptions().disable();
//开启跨域
http.cors();
http.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
//放行请求
.antMatchers("/user/register","/user/login").permitAll()
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 指定UserDetailService和加密器
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
}
登录接口: 在登录接口,我们需要手动实现AuthenticationManager组件调用authenticate方法进行校验,并将校验结果存到我们的上下文对象中,这里的Authentication是用来存储我们当前的用户信息,包括用户名,密码,权限。
@PostMapping("/login")
public String login(@RequestBody UserEntity user,HttpServletResponse response,HttpServletRequest request){
//生成一个包含用户名密码的认证信息
List<GrantedAuthority> tempAuthorities = new ArrayList<>();
//权限信息,后面再看,如果为null则会抛异常
tempAuthorities.add(new SimpleGrantedAuthority("test"));
Authentication token
= new UsernamePasswordAuthenticationToken(user.getUserName(), user.getUserPassword(),tempAuthorities);
Authentication authenticate = authenticationManager.authenticate(token);
//将返回的authentication存入上下文
SecurityContextHolder.getContext().setAuthentication(authenticate);
return "登录成功";
}
UserServiceImpl业务类: UserServiceImpl是实现了UserDetailsService接口,重写了loadUserByUsername方法,因为SpringSecurity默认会从内存中查询我们的用户信息,所以我们在自定义的时候需要需要实现我们自己的查询用户方法。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = userMapper.getUserByUserName(username);
//用来模拟从数据库中查出来的权限
List<SimpleGrantedAuthority> grantedAuthorityList = new ArrayList<>();
grantedAuthorityList.add(new SimpleGrantedAuthority("test"));
MyUser myUser = new MyUser(user,grantedAuthorityList);
if(Objects.isNull(user)){
throw new UsernameNotFoundException("没有找到该用户");
}
return myUser;
}
在之后的工作流程中,SpringSecurity会使用PasswordEncoder密码组件进行密码的编码和对比,所以我们要保证数据库中存的密码已经是PasswordEncoder加密过的才行。
注册接口:
@PostMapping("/register")
public String register(@RequestBody Map map){
String userName = (String) map.get("userName");
String password = (String) map.get("password");
if(StringUtils.isEmpty(userName) || StringUtils.isEmpty(password)){
return "参数不能为空";
}
UserEntity userEntity = new UserEntity();
userEntity.setUserName(userName);
userEntity.setUserPassword(passwordEncoder.encode(password));
//保存用户信息
userService.save(userEntity);
return "注册成功";
}
2.6 SpringSecurity实现权限授权
哈哈哈这篇文章烂尾了,由于工作生活上种种原因,这篇笔记的跨越时间太长了,会写但是不会表达,没能真正的消化,照搬别人的想法也没什么意思,暂时到此为止,后续会补充或者重写吧。关于授权看参考资料5即可明白,目前我再怎么写也不如这篇写得好。哈哈哈
3.参考资料
1.Java-使用Cookie实现登陆会话保持与注销功能
2.JAVA实现简单的Session登录,注销功能
3.Cookie和Session的作用和工作原理
4.sessionid如何产生?由谁产生?保存在哪里?
5.【项目实践】一文带你搞定Spring Security + JWT实现前后端分离下的认证授权