之所以想写这一系列,是因为之前工作过程中使用Spring Security,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0,关键是其风格和内部一些关键Filter大改,导致在配置同样功能时,多费了些手脚,因此花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,所有代码都在spring-security-study项目上:https://github.com/forever1986/spring-security-study.git
前面我们对Spring Security 6 的内容从底层原理到基本配置都讲了一遍,可能还有许多功能没有讲述,但是大部分应用常用功能都讲到了。作为这个系列的终章,想以一个生产环境的方式来做一次总结。过程中会使用到前面章节的内容,如果有些忘记的朋友可以去回顾一下。
1 前提条件
本次演示的基本架构图如下:
1)通过Nacos统一管理服务的配置
2)通过Redis存储token,存储手机验证码
3)通过MySQL存储用户数据和权限数据
- 启动一个Nacos,创建demo-biz三个命名空间,并创建demo-biz用户,分配demo-biz命名空间权限给demo-biz用户
- 启动一个Redis,可以预存入一个key= phone:13788888888 value=8633 的string值,用于手机验证码
- 启动一个MySQL,创建spring_security_study数据库,创建用户表和权限相关表。这里采用RBAC的权限控制方式。创建脚本如下:
-- spring_security_study.t_user 用户表 CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(100) NOT NULL, `password` varchar(100) NOT NULL, `email` varchar(100) DEFAULT NULL, `phone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- spring_security_study.t_permission 权限码表 CREATE TABLE `t_permission` ( `id` bigint NOT NULL AUTO_INCREMENT, `permission_code` varchar(200) NOT null, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- spring_security_study.t_role 角色表 CREATE TABLE `t_role` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_code` varchar(100) NOT null, `role_name` varchar(100) NOT null, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- spring_security_study.t_role_permission 角色与权限关联表 CREATE TABLE `t_role_permission` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL, `permission_id` bigint NOT null, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- spring_security_study.t_user_role 用户与角色关联表 CREATE TABLE `t_user_role` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `role_id` bigint NOT null, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; INSERT INTO spring_security_study.t_user (id, username, password, email, phone) VALUES(4, 'test', '{noop}1234', 'test@demo.com', '13788888888'); INSERT INTO spring_security_study.t_user (id, username, password, email, phone) VALUES(5, 'admin', '{noop}1234', 'admin@demo.com', '13766666666'); INSERT INTO spring_security_study.t_permission (id, permission_code) VALUES(1, 'PRODUCT_LIST'); INSERT INTO spring_security_study.t_permission (id, permission_code) VALUES(2, 'PRODUCT_EDIT'); INSERT INTO spring_security_study.t_role (id, role_code, role_name) VALUES(1, 'ADMIN', '管理员'); INSERT INTO spring_security_study.t_role (id, role_code, role_name) VALUES(2, 'USER', '普通用户'); INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(1, 1, 1); INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(2, 1, 2); INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(3, 2, 1); INSERT INTO spring_security_study.t_user_role (id, user_id, role_id) VALUES(1, 4, 2); INSERT INTO spring_security_study.t_user_role (id, user_id, role_id) VALUES(2, 5, 1);
本次演示的内容有:
- 通过数据库存储用户信息,也就是基于数据库的用户认证
- 基于RBAC权限模型,存在数据库权限进行验证
- 前后端分离,采用JWT认证(JWT采用非对称加密RSA),并且登录信息存储在Redis
- 配置信息存储在Nacos上面
- 包括:异常处理、手机验证码登录、白名单等其它功能
代码参考lesson12子模块
2 基础搭建
1)创建lesson12子模块,其pom引入如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--Spring Boot 提供的 Security 启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependencies>
2)创建controller包,其下设置DemoController
@RestController
public class DemoController {
@GetMapping("/demo")
public String demo() {
return"demo";
}
}
3)配置logback日志,在resources目录下新建logback-spring.xml
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<!-- logback-spring加载早于application.yml,如果直接通过${参数key}的形式是无法获取到对应的参数值-->
<!-- source指定的是application.yml配置文件中key,其它地方直接用${log.path}引用这个值 -->
<!-- 解决在相对路径下生成log.path_IS_UNDEFINED的问题,增加defaultValue -->
<springProperty scope="context" name="base.path" source="logging.file.path" defaultValue="${user.home}/logs"/>
<!-- app.name根据你的应用名称修改 -->
<springProperty scope="context" name="app.name" source="spring.application.name" defaultValue="applog"/>
<property name="log.path" value="${base.path}/${app.name}"/>
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度,%msg:日志消息,%n是换行符-->
<property name="log.pattern"
value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%property{app.name}] %X [%thread] %-5level %logger{36} -%msg%n"/>
<!-- 控制台日志输出配置 -->
<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${log.pattern}</pattern>
</encoder>
</appender>
<!-- 文件输出日志配置,按照每天生成日志文件 -->
<appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件输出的文件名称 -->
<FileNamePattern>${log.path}-%d{yyyy-MM-dd}.%i.log</FileNamePattern>
<!-- 日志保留天数 -->
<MaxHistory>30</MaxHistory>
<MaxFileSize>3MB</MaxFileSize>
<TotalSizeCap>500MB</TotalSizeCap>
<CleanHistoryOnStart>true</CleanHistoryOnStart>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
<pattern>${log.pattern}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="stdout"/>
<appender-ref ref="file"/>
</root>
<!-- mybatis日志配置 -->
<logger name="java.sql.Connection" level="DEBUG"/>
<logger name="java.sql.Statement" level="DEBUG"/>
<logger name="java.sql.PreparedStatement" level="DEBUG"/>
<logger name="org.mongodb.driver.connection" level="WARN"/>
<logger name="org.springframework.data.mongodb.core.MongoTemplate" level="DEBUG"/>
</configuration>
4)在yaml文件配置日志
# 日志配置
logging:
level:
com.demo: debug
5)创建启动类
@SpringBootApplication
public class SecurityLesson12Application {
public static void main(String[] args) {
SpringApplication.run(SecurityLesson12Application.class, args);
}
}
6)测试访问:http://127.0.0.1:8080/demo 需要登录后可访问
3 基于数据库用户认证
配置数据库,并配置自定义UserDetailsService,实现基于数据库的用户认证
1)在pom中引入mysql、mybatis、druid依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
2)在entity包下,新建TUser类
@Data
public class TUser {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
private String password;
private String email;
private String phone;
}
3)在mapper包下,新建TUserMapper。
@Mapper
public interface TUserMapper {
// 根据用户名,查询用户信息
@Select("select * from t_user where username = #{username}")
TUser selectByUsername(String username);
}
4)在service包下面,定义JdbcUserDetailsServiceImpl类,获取数据库用户
@Service
public class JdbcUserDetailsServiceImpl implements UserDetailsService {
@Autowired
private TUserMapper tUserMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询自己数据库的用户信息
TUser user = tUserMapper.selectByUsername(username);
if(user == null){
throw new UsernameNotFoundException(username);
}
return new LoginUserDetails(user);
}
}
5)配置yaml文件,增加数据库和mybatis-plus的配置
server:
port: 8080
spring:
# 配置数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/spring_security_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
druid:
initial-size: 5
min-idle: 5
maxActive: 20
maxWait: 3000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: false
filters: stat,wall,slf4j
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200
mybatis-plus:
global-config:
banner: false
mapper-locations: classpath:mappers/*.xml
type-aliases-package: com.demo.lesson05.entity
configuration:
cache-enabled: false
local-cache-scope: statement
# 日志配置
logging:
level:
com.demo: debug
4 前后端分离+JWT
屏蔽原有登录页面和登录流程,采用自定义的前后端分离接口以及JWT认证,其中生成token采用非对称加密,因此先生成密钥对放到resources目录下
生成jks,在命令行模式下,执行下面语句
keytool -genkeypair -alias demo -keyalg RSA -keypass linmoo -storepass linmoo -keysize 2048 -keystore demo.jks
这时候会在目录下生成一个demo.jks文件,该文件包括私钥和公钥 。该文件拷贝到项目resources文件下面,供签名使用
1)引入Redis、jjwt、fastjson等依赖
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
2)在config包下,配置Redis的配置RedisConfiguration
@Configuration
public class RedisConfiguration {
/**
* 主要做redis配置。redis有2种不同的template(2种的key不能共享)
* 1.StringRedisTemplate:以String作为存储方式:默认使用StringRedisTemplate,其value都是以String方式存储
* 2.RedisTemplate:
* 1)使用默认RedisTemplate时,其value都是根据jdk序列化的方式存储
* 2)自定义Jackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是LinkedHashMap
* 3)自定义GenericJackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是原先对象(因为保存了classname)
*/
@Bean
@ConditionalOnMissingBean({RedisTemplate.class})
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate();
template.setConnectionFactory(factory);
//本实例采用GenericJackson2JsonRedisSerializer
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
@ConditionalOnMissingBean({StringRedisTemplate.class})
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(factory);
return template;
}
}
3)在entity包下面,配置前后端交互参数LoginDTO,以及自定义UserDetails的LoginUserDetails
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginDTO {
private String username;
private String password;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {
private TUser tUser;
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getPassword() {
return tUser.getPassword();
}
@Override
public String getUsername() {
return tUser.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
4)在utils包下面建立JwtUtil,用于生成和解析JWT的token(注意:这里使用非对称加密RSA256,确保已经生成密钥对文件)
/**
* JWT工具类
*/
@Component
@ConfigurationProperties("rsa")
public class JwtUtil {
//有效期为
private final Long JWT_TTL = 60 * 60 * 1000L; // 60 * 60 *1000 一个小时
//设置秘钥密码
@Setter
private String key;
//设置密钥名称
@Setter
private String jks;
//密钥对
private KeyPair keyPair;
public String getUUID(){
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 创建token
*/
public String createToken(String subject) {
return getJwtBuilder(subject, null, getUUID());// 设置过期时间
}
/**
* 创建token
*/
public String createToken(String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
}
/**
* 创建token
*/
public String createToken(String id, String subject, Long ttlMillis) {
return getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
}
/**
* 解析token
*/
public String parseJWT(String token) throws Exception {
KeyPair tempKeyPair = keyPair();
return Jwts.parser()
.setSigningKey(tempKeyPair.getPublic())
.parseClaimsJws(token)
.getBody().getSubject();
}
/**
* 生成token
*/
private String getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.RS256;
KeyPair tempKeyPair = keyPair();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("spring-security-study") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, tempKeyPair.getPrivate()) //使用RS256非对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate)
.compact();
}
/**
* 密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
if(keyPair!=null)
return keyPair;
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource(jks), key.toCharArray());
keyPair = factory.getKeyPair("demo", key.toCharArray());
return keyPair;
}
}
5)在service包下面定义登录和登出接口,LoginService接口以及其实现类LoginServiceImpl
public interface LoginService {
Result<String> login(LoginDTO loginDTO);
Result<String> logout();
}
@Service
public class LoginServiceImpl implements LoginService {
// 注入AuthenticationManagerBuilder,用于获得authenticationManager
@Autowired
private AuthenticationManagerBuilder authenticationManagerBuilder;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private JwtUtil jwtUtil;
private static String PRE_KEY = "user:";
@Override
public Result<String> login(LoginDTO loginDTO) {
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
if(authentication!=null && authentication.isAuthenticated()){
SecurityContextHolder.getContext().setAuthentication(authentication);
LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
String subject = PRE_KEY + user.getTUser().getId();
String token = jwtUtil.createToken(subject, 1000*60*5L);
redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
return Result.success(token);
}
}catch (AuthenticationException e){
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
}
return Result.failed("认证失败");
}
@Override
public Result<String> logout() {
if(SecurityContextHolder.getContext().getAuthentication()!=null){
LoginUserDetails user = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(user!=null){
String key = PRE_KEY + user.getTUser().getId();
redisTemplate.delete(key);
}else {
return Result.failed("登出失败,用户不存在");
}
}
return Result.success("登出成功");
}
}
6)在controller包下,定义LoginController
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
return loginService.login(loginDTO);
}
@PostMapping("/logout")
public Result<String> logout() {
return loginService.logout();
}
}
7)在yaml文件中增加密钥的密码和密钥对名称
# 密钥加密密码
rsa:
key: linmoo
jks: demo.jks
8)在filter包下,定义JwtAuthenticationTokenFilter,用于Security过滤器链判断JWT
/**
* 用于集成JWT认证
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 过滤login接口
if("/login".equals(request.getRequestURI())){
filterChain.doFilter(request, response);
return;
}
// 从请求头获取token
String token = request.getHeader("access_token");
// 检查获取到的token是否为空或空白字符串。(判断给定的字符串是否包含文本)
if (!StringUtils.hasText(token)) {
// 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。
filterChain.doFilter(request, response);
return;
}
// 解析token
String userAccount;
try {
userAccount = jwtUtil.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
throw new BadCredentialsException("token非法");
}
// 临时缓存中 获取 键 对应 数据
Object object = redisTemplate.opsForValue().get(userAccount);
LoginUserDetails loginUser = (LoginUserDetails)object;
if (Objects.isNull(loginUser)) {
throw new BadCredentialsException("用户未登录");
}
// 将用户信息存入 SecurityConText
// UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
// SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。
// 将用户名 密码 权限的集合存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}
9)在config包下,配置SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login").permitAll().anyRequest().authenticated())
// 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable);
return http.build();
}
}
10)使用postman测试
使用post方法访问:http://127.0.0.1:8080/login
使用get方法访问:http://127.0.0.1:8080/demo
5 异常处理
1)在handler包下,定义认证异常DemoAuthenticationEntryPoint
public class DemoAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
*
* @param request 请求request
* @param response 请求response
* @param authException 认证的异常
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// authException.getLocalizedMessage()返回本地化语言的错误信息
Result<String> result = Result.failed(authException.getLocalizedMessage());
String json = JSON.toJSONString(result);
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(json);
}
}
2)在handler包下,定义授权异常DemoAccessDeniedHandler
public class DemoAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// accessDeniedException.getLocalizedMessage()返回本地化语言的错误信息
Result<String> result = Result.failed(accessDeniedException.getLocalizedMessage());
String json = JSON.toJSONString(result);
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(json);
}
}
3)在config包下的SecurityConfig类,加入2个异常处理类
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login").permitAll().anyRequest().authenticated())
// 异常处理
.exceptionHandling(handling-> handling
.accessDeniedHandler(new DemoAccessDeniedHandler())
.authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
// 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable);
return http.build();
}
}
6 手机验证码登录
- 这里与前面系列10中的手机验证码有一点不一样,这里是前后端分离,因此我们只需要自定义基于手机验证的AuthenticationProvider和AbstractAuthenticationToken即可,不需要定义Filter
- 验证码我们采用phone:+手机号码,在Redis中预先存入一个key=phone:13788888888,value=8633 的String
1)在authentication包下,定义手机验证的AbstractAuthenticationTokenPho继承类neCodeAuthenticationToken
/**
* 手机验证码token
*/
public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
public PhoneCodeAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public PhoneCodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
}
2)在authentication包下,定义手机验证的AuthenticationProvider
public class PhoneCodeAuthenticationProvider implements AuthenticationProvider {
private TUserMapper tUserMapper;
private RedisTemplate redisTemplate;
private static String PRE_PHONE_KEY = "phone:";
public PhoneCodeAuthenticationProvider(TUserMapper tUserMapper, RedisTemplate redisTemplate) {
this.tUserMapper = tUserMapper;
this.redisTemplate = redisTemplate;
}
/**
* 支持PhoneCodeAuthenticationToken认证
*/
@Override
public boolean supports(Class<?> aClass) {
return PhoneCodeAuthenticationToken.class.isAssignableFrom(aClass);
}
/**
* 认证
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!supports(authentication.getClass())) {
return null;
}
PhoneCodeAuthenticationToken token = (PhoneCodeAuthenticationToken) authentication;
// 判断电话是否为空
String phone = (String) token.getPrincipal();
if (phone == null) {
throw new BadCredentialsException("无法获取电话信息");
}
String code = (String) token.getCredentials();
if (code == null) {
throw new BadCredentialsException("验证码为空");
}
// 这里应该是从另外发送短信服务中存入验证码
// redisTemplate.opsForValue().set(PRE_PHONE_KEY + phone, "8633", 1000*60*5L, TimeUnit.MILLISECONDS);
try {
// 从Redis中获取预先存入的验证码
Object object = redisTemplate.opsForValue().get(PRE_PHONE_KEY + phone);
if (!object.equals(code)) {
throw new BadCredentialsException("验证码错误");
}
} catch (Exception e) {
e.printStackTrace();
throw new BadCredentialsException("验证码无效");
}
TUser tUser = tUserMapper.selectByPhone(phone);
if( tUser == null){
throw new BadCredentialsException("找不到用户");
}
// 这里PhoneCodeAuthenticationToken第三个入参应该是权限,这里没有使用数据库,就默认一个空值。完整情况应该是前面通过phone获取到用户,再找到权限
PhoneCodeAuthenticationToken result =
new PhoneCodeAuthenticationToken(tUser, code, new ArrayList<>());
result.setDetails(token.getDetails());
return result;
}
}
3)在mapper包下的TUserMapper,增加基于手机号搜索用户的接口
@Mapper
public interface TUserMapper {
// 根据用户名,查询用户信息
@Select("select * from t_user where username = #{username}")
TUser selectByUsername(String username);
// 根据电话号码,查询用户信息
@Select("select * from t_user where phone = #{phone}")
TUser selectByPhone(String phone);
}
4)在service包下,LoginService和其实现类LoginServiceImpl,增加loginPhoneCode接口,同时改造一下,将原先自动注入的AuthenticationManagerBuilder改为AuthenticationManager
public interface LoginService {
Result<String> login(LoginDTO loginDTO);
Result<String> loginPhoneCode(LoginDTO loginDTO);
Result<String> logout();
}
@Service
public class LoginServiceImpl implements LoginService {
// 注入AuthenticationManager
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private JwtUtil jwtUtil;
private static String PRE_KEY = "user:";
@Override
public Result<String> login(LoginDTO loginDTO) {
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
Authentication authentication = authenticationManager.authenticate(authenticationToken);
if(authentication!=null && authentication.isAuthenticated()){
SecurityContextHolder.getContext().setAuthentication(authentication);
LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
String subject = PRE_KEY + user.getTUser().getId();
String token = jwtUtil.createToken(subject, 1000*60*5L);
redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
return Result.success(token);
}
}catch (AuthenticationException e){
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
}
return Result.failed("认证失败");
}
@Override
public Result<String> loginPhoneCode(LoginDTO loginDTO) {
String username = loginDTO.getPhone();
String password = loginDTO.getCode();
PhoneCodeAuthenticationToken authenticationToken = new PhoneCodeAuthenticationToken(username, password);
try {
Authentication authentication = authenticationManager.authenticate(authenticationToken);
if(authentication!=null && authentication.isAuthenticated()){
SecurityContextHolder.getContext().setAuthentication(authentication);
TUser tUser = (TUser)authentication.getPrincipal();
String subject = PRE_KEY + tUser.getId();
String token = jwtUtil.createToken(subject, 1000*60*5L);
LoginUserDetails loginUserDetails = new LoginUserDetails(tUser);
redisTemplate.opsForValue().set(subject, loginUserDetails, 1000*60*5L, TimeUnit.MILLISECONDS);
return Result.success(token);
}
}catch (AuthenticationException e){
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
}
return Result.failed("认证失败");
}
@Override
public Result<String> logout() {
if(SecurityContextHolder.getContext().getAuthentication()!=null){
LoginUserDetails user = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(user!=null){
String key = PRE_KEY + user.getTUser().getId();
redisTemplate.delete(key);
}else {
return Result.failed("登出失败,用户不存在");
}
}
return Result.success("登出成功");
}
}
5)在controller包下的LoginController,增加基于手机验证码登录接口
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO) {
return loginService.login(loginDTO);
}
@PostMapping("/loginphone")
public Result<String> loginPhone(@RequestBody LoginDTO loginDTO) {
return loginService.loginPhoneCode(loginDTO);
}
@PostMapping("/logout")
public Result<String> logout() {
return loginService.logout();
}
}
6)JwtAuthenticationTokenFilter类过滤掉"/loginphone"
7)修改SecurityConfig 配置,自定义PasswordEncoder ,自定义AuthenticationManager(加入原先的密码验证和手机验证的Provider)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private TUserMapper tUserMapper;
@Autowired
private RedisTemplate redisTemplate;
@Bean
public PhoneCodeAuthenticationProvider phoneCodeAuthenticationProvider() {
return new PhoneCodeAuthenticationProvider(tUserMapper, redisTemplate);
}
/**
* 定义密码加密方式,用于DaoAuthenticationProvider
*/
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* 定义AuthenticationManager,加入两种AuthenticationProvider
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
// 保留原来账号密码登录的AuthenticationProvider
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
// 将daoAuthenticationProvider和 phoneCodeAuthenticationProvider 加入到authenticationManager
return new ProviderManager(daoAuthenticationProvider, phoneCodeAuthenticationProvider());
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login", "/loginphone").permitAll().anyRequest().authenticated())
// 异常处理
.exceptionHandling(handling-> handling
.accessDeniedHandler(new DemoAccessDeniedHandler())
.authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
// 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable)
// 设置全局authenticationManager
.authenticationManager(authenticationManager());
return http.build();
}
}
8)测试
使用Postman,分别访问:
都可以登录成功。
7 白名单
这里我们将原先login和loginphone两个接口放入白名单中
1)在entity包下,新建WhiteDTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WhiteDTO {
private String httpMethod;
private String url;
}
2)在properties包下,新建IgnoreUrlsProperties读取yaml配置
@Data
@ConfigurationProperties("security")
public class IgnoreUrlsProperties {
private List<WhiteDTO> ignoreUrls = new ArrayList<>();
public RequestMatcher[] getRequestMatcher(){
List<RequestMatcher> requestMatchers = new ArrayList<>();
ignoreUrls.stream().map(whiteDTO->new AntPathRequestMatcher(whiteDTO.getUrl(), whiteDTO.getHttpMethod())).forEach(requestMatchers::add);
return requestMatchers.toArray(new RequestMatcher[0]);
}
}
3)SecurityConfig 配置下,增加白名单
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(IgnoreUrlsProperties.class)
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private TUserMapper tUserMapper;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private IgnoreUrlsProperties ignoreUrlsProperties;
@Bean
public PhoneCodeAuthenticationProvider phoneCodeAuthenticationProvider() {
return new PhoneCodeAuthenticationProvider(tUserMapper, redisTemplate);
}
/**
* 定义密码加密方式,用于DaoAuthenticationProvider
*/
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* 定义AuthenticationManager,加入两种AuthenticationProvider
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
// 保留原来账号密码登录的AuthenticationProvider
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
// 将daoAuthenticationProvider和 phoneCodeAuthenticationProvider 加入到authenticationManager
return new ProviderManager(daoAuthenticationProvider, phoneCodeAuthenticationProvider());
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth->auth
//允许/login访问
.requestMatchers("/login", "/loginphone").permitAll()
// 加入免鉴权白名单
.requestMatchers(ignoreUrlsProperties.getRequestMatcher()).permitAll()
.anyRequest().authenticated())
// 异常处理
.exceptionHandling(handling-> handling
.accessDeniedHandler(new DemoAccessDeniedHandler())
.authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
// 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
.csrf(AbstractHttpConfigurer::disable)
// 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
.addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
// 由于采用token方式认证,因此可以关闭session管理
.sessionManagement(SessionManagementConfigurer::disable)
// 禁用原来登录页面
.formLogin(AbstractHttpConfigurer::disable)
// 禁用系统原有的登出
.logout(LogoutConfigurer::disable)
// 设置全局authenticationManager
.authenticationManager(authenticationManager());
return http.build();
}
}
4)yaml文件增加白名单配置
# 白名单
security:
ignoreUrls:
- httpMethod: 'POST'
url: '/login'
- httpMethod: 'POST'
url: '/loginphone'
5)测试login和loginphone接口,看看是否可以访问
8 基于数据库权限认证
首先,我们是基于RBAC权限模型,因此数据库中新增几个表(这个在前提条件中已经加入,这里只是提一下这里使用的)
里面有用户user和admin,其中user是普通用户角色,拥有PRODUCT_LIST权限;admin是管理员角色,拥有PRODUCT_LIST和PRODUCT_EDIT权限
1)在mapper包下,新增TPermissionMapper查询权限码
@Mapper
public interface TPermissionMapper {
// 根据用户名,查询用户信息
@Select("select distinct p.permission_code from t_user u\n" +
"left join t_user_role ur on ur.user_id = u.id\n" +
"left join t_role_permission rp on ur.role_id = rp.role_id \n" +
"left join t_permission p on p.id = rp.permission_id \n" +
"where u.id = #{id}")
List<String> selectPermissionByUserId(Long id);
}
2)LoginUserDetails新增permissions,并改造getAuthorities()方法
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {
private TUser tUser;
private List<String> permissions;
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<>();
if(permissions!=null){
permissions.forEach(permission-> list.add(new SimpleGrantedAuthority(permission)));
}
return list;
}
@Override
public String getPassword() {
return tUser.getPassword();
}
@Override
public String getUsername() {
return tUser.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3)修改JdbcUserDetailsServiceImpl和LoginServiceImpl,将使用TPermissionMapper 查询到权限,并设置到
4)修改JwtAuthenticationTokenFilter,存储权限集合到Context中
5)SecurityConfig增加@EnableMethodSecurity注解,使用注解方式设置权限
6)在controller包下的DemoController 新增2个方法list和edit,并设置权限
@RestController
public class DemoController {
@GetMapping("/demo")
public String demo() {
return"demo";
}
@GetMapping("/list")
@PreAuthorize("hasAuthority('PRODUCT_LIST')")
public String list() {
return"list";
}
@GetMapping("/edit")
@PreAuthorize("hasAuthority('PRODUCT_EDIT')")
public String edit() {
return"edit";
}
}
7)测试
我们从数据库表中知道user用户只用PRODUCT_LIST权限,而admin用户有PRODUCT_LIST和PRODUCT_EDIT权限。分别登录user和admin用户,测试 http://127.0.0.1:8080/list 和 http://127.0.0.1:8080/edit
最终效果是:user可以访问 http://127.0.0.1:8080/list ,无法访问 http://127.0.0.1:8080/edit ;admin两个接口都可以访问
9 基于nacos的配置
现实项目中,配置基本上放在配置中心,因此我们这里使用一个nacos。在demo-biz命名空间中配置3个名为demo-biz-service
y的aml文件,分别对于开发、测试和正式环境
1) nacos上面配置3个demo-biz-service的内容如下:(如果自己端口等配置有所改动,可以自行修改)
spring:
# 配置数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/spring_security_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
druid:
initial-size: 5
min-idle: 5
maxActive: 20
maxWait: 3000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: false
filters: stat,wall,slf4j
connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200
#redis配置
data:
redis:
host: 127.0.0.1
mybatis-plus:
global-config:
banner: false
mapper-locations: classpath:mappers/*.xml
type-aliases-package: com.demo.lesson05.entity
configuration:
cache-enabled: false
local-cache-scope: statement
# 日志配置
logging:
level:
com.demo: debug
# 密钥加密密码
rsa:
key: linmoo
jks: demo.jks
# 白名单
security:
ignoreUrls:
- httpMethod: 'POST'
url: '/login'
- httpMethod: 'POST'
url: '/loginphone'
2)将原先resources下的application.yml删除
3)在resources下新增bootstrap.yml,内容如下:
server:
port: 8080
spring:
# 应用名称
application:
name: demo-biz-service
# 激活环境
profiles:
active: dev
# nacos配置
cloud:
nacos:
config:
server-addr: 127.0.0.1:8848
namespace: demo-biz
group: ${spring.profiles.active}
file-extension: yaml
username: demo-biz
password: demo-biz
logging:
level:
com.demo.client: debug
4)父项目spring-security-study,其pom引入springcloud的依赖
<!-- 引入spring cloud alibaba依赖-->
<properties>
<spring-cloud.version>2023.0.3</spring-cloud.version>
<spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>
</properties>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 引入spring cloud依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
5)lesson12子模块的pom引入以下依赖:
<!-- 引入nacos config的依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 新版本bootstrap已经从spring-cloud-starter-config移除,因此需要手动依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
6)启动项目验证登录和访问
结语:我们通过12章的系列,从底层原理讲到实际应用。基本上揽括了Spring Security 6的功能,当然还有许多功能没有谈到,比如CRSF 、Remember me、密码升级等等,我相信学习完这一系列后,你如果有兴趣了解Spring Security的其它功能,对你来说应该易如反掌。另外现在内部微服务做统一登录认证,都会使用网关+OAuth2方式,这部分我们将会单独开一个系列来讲解。