关于csmall-passport项目
项目GITEE地址:gitee.com/chengheng20…
此项目主要用于实现“管理员”账号的后台管理功能,主要实现:
- 管理员登录
- 添加管理员
- 删除管理员
- 显示管理员列表
- 启用 / 禁用管理员
- 其它相关
此项目的定位,可以是整套项目(包含csmall-product及其它相关相册)的共用的“登录入口”,即在其它项目中不必实现“登录”的功能,而是在此项目(csmall-passport)中实现即可,在此项目上成功登录的管理员,在其它相关相册中不必重复登录,也能被识别身份与权限。
关于RBAC
RBAC:Role-Based Access Control,基于角色的访问控制
在涉及权限管理员的软件设计中,应该至少需要设计以下3张数据表:
-
账号表(管理员表,或用户表):
ams_admin,用于记录管理员的账号 -
角色表:
ams_role,与账号表关联,并且,与权限表关联 -
权限表:
ams_permission,用于记录此项目中所有的权限(权限标识)
并且,还至少需要2张关联表
- 账号与角色的关联表:
ams_admin_role - 角色与权限的关联表:
ams_role_permission
关于Spring Security框架
Spring Security框架主要解决了认证与授权相关的问题。
**认证:**判断尝试执行访问时的身份信息,例如:判断是否已经登录,或登录的账号是哪一个。最核心的功能就是登录
**授权:**判断是否允许已经通过认证的访问是否可以执行,例如:判断是否具有权限执行某项操作
添加Spring Security的依赖
在基于Spring Boot的项目中,使用Spring Security应该添加依赖项:
<!-- Spring Boot支持Spring Security的依赖项,主要解决认证与授权相关问题 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当在项目中添加了以上依赖项后,你的项目会发生以下变化(Spring Boot中的Spring Security自动配置的默认行为):
-
所有的请求都是必须要登录才允许访问的,包括不存在URL
-
提供了默认的登录页面,当未登录时,会自动重定向到此登录页面
-
提供了临时的默认的登录账号,用户名是
user,密码是启动项目时在控制台中可以看到的UUID值(每次重启项目后都会重新生成)
-
默认使用Session存储用户的登录信息,所以,登录后的有效期取决于Session的有效期
-
当登录成功后,会自动重定向到此前尝试访问的URL,如果此前没有尝试访问某个URL,则重定向到根路径
-
可以通过
/logout路径 访问 ”退出登录“的页面,以实现退出所有 -
即使登录成功了,所有
POST请求都是不允许的,而GET请求都是允许的
关于Spring Security的配置类
在项目的根包下,创建config.SecurityConfiguration类,继承自WebSecurityConfigurerAdapter类,并在类上添加@Configuration注解:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
}
然后,在类中重写void configure(HttpSecurity http)方法:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// super.configure(http); // 不要调用父类的同款方法
}
}
**注意:**在以上配置方法中,不要使用super调用父类的同款方法!
由于没有调用父类的同款方法,再次重启项目后,与此前将会有些不同:
- 所有请求都不再要求登录
- 默认的登录、登出的URL不可用
关于登录表单
在Spring Security的配置类中的configure(HttpSecurity http)方法中,根据是否调用了参数对象的formLogin()方法,决定是否启用登录表单页(/login)和登录页(/logout)及对应的功能,例如:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 调用formLogin()表示启用登录和登出页面,如果未调用此方法,则没有登录和登出页面
http.formLogin();
// super.configure(http); // 不要调用父类的同款方法
}
关于URL的授权访问
在Spring Security的配置类中的configure(HttpSecurity http)方法中进行授权访问的配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 白名单
String[] urls = {
"",
"/",
"/index.html",
"/helloworld"
};
// 配置URL的授权访问
// 注意:配置时,各请求的授权访问遵循“第一匹配原则”,即根据代码从上至下,以第1次匹配到的规则为准
// 所以,在配置时,必须将更加精准的配置写在前面,覆盖范围更大的匹配的配置写在后面
http.authorizeRequests() // 配置URL的授权访问
.mvcMatchers(urls) // 匹配某些请求
.permitAll() // 直接许可,即:不需要通过认证就可以访问
.anyRequest() // 匹配任何请求
.authenticated() // 以上匹配到的请求必须是“已经通过认证的”
;
// 调用formLogin()表示启用登录和登出页面,如果未调用此方法,则没有登录和登出页面
http.formLogin();
// super.configure(http); // 不要调用父类的同款方法
}
使用临时的自定义账号实现登录
如果不想使用Spring Security默认的用户名和密码登录,而是改为自定义的账号信息来登录,例如,假定root是正确的用户名,匹配的密码是1234,需要自定义类,实现UserDetailsService接口,并确保此类是一个组件类,则Spring Security框架会基于此实现类来处理认证。
在项目的根包下去创建security.UserDetailsServiceImpl类,在类上添加@Service注解,并重写接口中的抽象方法:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
return null;
}
}
当项目中存在UserDetailsService接口类型的组件对象时,当尝试处理登录时,Spring Security会自动的使用登录表单提交过来的用户名来调用以上loadUserByUsername()方法,并得到UserDetails类型的对象,此对象中应该包含用户的相关信息,例如密码、账号状态等,接下来,Spring Security会自动的判断账号的状态,并使用登录表单提交过来的密码与UserDetails中的密码进行对比,以决定此账号是否能够登录。
所以,当重写loadUserByUsername()时,只需要实现“根据用户名返回匹配的用户详情”即可,至于此方法的调用、返回的结果如何用于判断是否能够成功登录,都是由Spring Security自动处理的!
则重写方法:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);
// 假设正确的用户名是root,匹配的密码是1234
if (!"root".equals(s)) {
log.warn("用户名【{}】错误,将不会返回有效的UserDetails(用户详情)", s);
return null;
}
UserDetails userDetails = User.builder() // 构建者模式
.username("root") // 存入用户名
.password("1234") // 存入密码
.disabled(false) // 存入启用、禁用状态
.accountLocked(false) // 存入账号是否锁定的状态
.credentialsExpired(false) // 存入凭证是否过期的状态
.accountExpired(false) // 存入账号是否过期的状态
.authorities("这是一个临时的山寨权限,暂时没什么用") // 存入权限列表
.build(); // 执行构建,得到UserDetails类型的对象
log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", userDetails);
return userDetails;
}
注意:Spring Security在验证登录时,要求密码必须经过加密处理,即:在loadUserByUsername()方法中返回的UserDetails中的密码必须是密文,即使你执意不加密,也必须明确的表示出来!
在SecurityConfiguration类中,通过@Bean方法来配置PasswordEncoder,并返回某个密码编码器对象,Spring Security会自动使用它来验证密码,例如,可以使用NoOpPasswordEncoder,表示“不对密码进行加密处理”,例如:
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
提示:当项目中存在UserDetailsService类型的组件对象时,Spring Security框架不再提供默认的账号(用户名为user,密码为启动时的UUID值的账号),所以,启动项目时也不会看到临时的UUID密码了。
关于BCrypt算法
BCrypt算法是主流的、最安全的用于处理密码加密的算法之一!其主要特征有:
- 密文长度固定,是60位的
- 使用了随机的盐值,所以,使用相同的原文反复加密会得到不同的密文
- 运算效率极低,大多家用电脑在强度10时,每秒只能运算13次左右
- 由于运算效率极低,不容易被暴力破解
public class BCryptTests {
// BCryptPasswordEncoder的构造方法的int参数表示“强度”
// 强度表示加密时会执行2的多少次方的哈希运算
// 如果使用无参数构造方法,则强度值为10
// 建议不要使用15以上的强度值
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Test
void encode() {
String rawPassword = "123456";
System.out.println("原文:" + rawPassword);
long start = System.currentTimeMillis();
for (int i = 0; i < 13; i++) {
String encodedPassword = passwordEncoder.encode(rawPassword);
System.out.println("密文:" + encodedPassword);
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
@Test
void matches() {
String rawPassword = "123456";
System.out.println("原文:" + rawPassword);
String encodedPassword = "$2a$10$ZWwzVRwZGI/aKEmgcrwid.Ch6pKySRgtQrSMQBf3YNGeTDSiGIKQq";
System.out.println("密文:" + encodedPassword);
boolean result = passwordEncoder.matches(rawPassword, encodedPassword);
System.out.println("对比结果:" + result);
}
}
使用数据库中账号实现登录
使用数据库中的账号实现登录,需要实现“根据用户名查询用户的登录信息”,需要执行的SQL语句大致是:
select id, username, password, enable from ams_admin where username=?
首先,需要在项目的根包下创建pojo.vo.AdminLoginInfoVO类:
@Data
public class AdminLoginInfoVO implements Serializable {
private Long id;
private String username;
private String password;
private Integer enable;
}
然后,在AdminMapper.java中添加抽象方法:
/**
* 根据用户名查询管理员的登录信息
*
* @param username 用户名
* @return 匹配的登录信息,如果没有匹配的数据,则返回null
*/
AdminLoginInfoVO getLoginInfoByUsername(String username);
并在AdminMapper.xml中配置:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">
SELECT
<include refid="LoginInfoQueryFields"/>
FROM
ams_admin
WHERE
username=#{username}
</select>
<sql id="LoginInfoQueryFields">
<if test="true">
id, username, password, enable
</if>
</sql>
<resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
</resultMap>
完成后,在AdminMapperTests中测试:
@Test
void getLoginInfoByUsername() {
String username = "root";
Object queryResult = mapper.getLoginInfoByUsername(username);
log.debug("根据用户名【{}】查询登录信息完成,查询结果:{}", username, queryResult);
}
接下来,调整UserDetailsServiceImpl中的loadUserByUsername()方法,改为“通过数据库查询管理员详情并返回”,例如:
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security自动调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
log.debug("根据用户名【{}】查询登录信息,结果:{}", s, loginInfo);
if (loginInfo == null) {
String message = "用户名不存在,将无法返回有效的UserDetails对象,则返回null";
log.warn(message);
return null;
}
log.debug("开始创建返回给Spring Security的UserDetails对象……");
UserDetails userDetails = User.builder() // 构建者模式
.username(loginInfo.getUsername()) // 存入用户名
.password(loginInfo.getPassword()) // 存入密码
.disabled(loginInfo.getEnable() != 1) // 存入启用、禁用状态
.accountLocked(false) // 存入账号是否锁定的状态
.credentialsExpired(false) // 存入凭证是否过期的状态
.accountExpired(false) // 存入账号是否过期的状态
.authorities("这是一个临时的山寨权限,暂时没什么用") // 存入权限列表
.build(); // 执行构建,得到UserDetails类型的对象
log.debug("即将向Spring Security返回UserDetails类型的对象,返回结果:{}", userDetails);
return userDetails;
}
由于数据库的密码是基于BCrypt算法进行加密处理的,所以,还需要替换掉SecurityConfiguration中通过@Bean方法配置的密码编码器,需要改为BCryptPasswordEncoder,例如:
@Bean
public PasswordEncoder passwordEncoder() {
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
完成后,可以重启项目,测试登录。
关于防止伪造的跨域攻击
**伪造的跨域攻击:**此类攻击是基于“服务器对客户端的浏览器的信任”,例如,用户在浏览器的第1个选项卡中登录了,那么,在第2个、第3个等等同一个浏览器的其它选项卡中访问同样的服务器,也会被视为“已登录”的状态。所以,假设某个用户在浏览器的第1个选项卡中登录了网上银行,此用户在第2个选项卡中打开了另一个网站,此网站可能是恶意的网站(不是此前第1个选项卡的网上银行的网站),在恶意网站中隐藏了一个向网上银行的网站发起请求的链接,并自动发出了请求(比较典型的做法是将链接设置为<img>标签的src属性值,并隐藏此<img>标签使之不显示),则会导致在第2个选项卡中的恶意网站被打开时,就自动的向网上银行发起了请求,而网上银行收到了请求后,会视为“已登录”的状态!
<img src="http://网上银行1/转账?目标=坏人&金额=500" style="display: none;"/>
<img src="http://网上银行2/转账?目标=坏人&金额=500" style="display: none;"/>
<img src="http://网上银行3/转账?目标=坏人&金额=500" style="display: none;"/>
当然,以上只是模拟的情况,并不可能真正的实现转账,毕竟当下的网上银行的转账等涉及财产的保护措施很多,不会收到请求就直接执行转账,通常可能需要输入密码、短信验证码等等,但是,对于防护措施不完全的网站,仍可能受到此类攻击!
**典型的防御机制:**在不是前后端分离的开发模式下,服务器端在生成表单时,会在表单中隐藏一个具有“唯一性”或很强的“随机性”的值,正常提交表单时,此值会随着表单中的其它数据一并提交到服务器端,服务器端会接收并检查这个“唯一”、“随机”的值,如果这个值与生成表单时的相同,则视为正常请求,否则,将视为“伪造的跨域攻击”!
以Spring Security默认的登录表单为例:
当Spring Security启用了“防止伪造的跨域攻击”的防御机制后,所有POST请求的请求参数中都必须携带以上名为_csrf的请求参数,且参数必须是服务器端生成的值,由于我们自行开发的功能并不知道此值是多少,无法携带这个请求参数,所以,默认情况下,所有POST请求都会被视为“伪造的跨域攻击”,服务器端会响应403错误!
在“前后端分离”的项目中,由于服务器端不再负责生成各表单页面,也就无法在表单中添加以上_csrf的请求数据,则提交请求时,不可能提交正确的_csrf参数,所以,这种防御机制在前后端分离的项目中并不适用!
在SecurityConfiguration中的configurer(HttpSecurity http)方法中添加配置,关闭“防止伪造的跨域攻击”这种防御机制:
http.csrf().disable();
重启后,POST类型的请求不再是403错误!
附:关于权限设计
当用户的数量很多时:
【用户与角色的关联表】
1 1:红钻
1 2:绿钻
1 3:黑钻
2 1:红钻
3 1:红钻
4 1:红钻
5 1:红钻
【角色与权限的关联表】
1 xx
1 xx
1 xx
2 xx
当管理员的数量较少时:
【管理员与权限的关联表】
管理员ID 权限ID
1 1
1 2
1 3
1 4
1 5
1 6
2 3
2 4
2 5
3 1
3 3
3 5
4 2
4 3
4 4
5 2
5 3
5 4
6 2
6 3
6 4
7 2
7 3
7 4
8 2
8 3
8 4