1.SpringBoot-Security技术
1.1 技术介绍
安全框架,权限控制的框架,包括认证功能、授权功能、加密功能、会话管理、缓存支持、记住登录信息等功能。
1.2 技术注意点
1.2.1 使用了spring security 后,@controller注解不能写地址
1.2.2 @PreAuthorize注解一定要使用权限时才使用,如需要开放某接口时,则禁止使用,否则无法访问,并且排查异常麻烦,没有错误日志。
1.2.3 权限控制的方法
方法 | 作用 | 用法 |
---|---|---|
hasAuthority() | 是否有指定权限,有则true,没有则false | http.hasAuthority(“admins”) |
hasAnyAuthority() | 是否有多个指定权限,有则true,没有则false | http.hasAnyAuthoity(“admins”,“hellos”) |
hasRole() | 是否有指定角色就能够访问,否则403 | http.hasRole(“ADMIN”) |
hasAnyRole() | 用户具备一个角色权限就可以访问,否则403 | http.hasAnyRole(“ADMIN”,“TEST”) |
@Secured() | 判断是否有某个角色权限,需要匹配前缀,添加字符串"ROLE_" | @Secured({“ROLE_ADMIN”,“ROLE_SALE”}) |
@PreAuthorize() | 判断进入方法前的权限验证,不需要加字符串"ROLE_",底层默认添加 | @PreAuthorize(“hasAnyAuthority(‘sys:test’)”) |
@PostAuthorize() | 判断在方法执行后的权限验证,适合带有返回值的验证 | @PostAuthorize(“hasRole(‘ADMIN’)”) |
1.3 完整项目地址
链接:项目地址
提取码:d1e9
1.5.2 数据库进阶案例
1. 增加新依赖
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
2.准备sql
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of users
-- ----------------------------
INSERT INTO `users` VALUES (1, 'admin', '$2a$10$Ug6eGqhTX7wY8ZVIe2PbC.ljVWEko5h7ZH92N.rg0ZjZBcgg2VOqm');
SET FOREIGN_KEY_CHECKS = 1;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for persistent_logins
-- ----------------------------
DROP TABLE IF EXISTS `persistent_logins`;
CREATE TABLE `persistent_logins` (
`username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`series` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`token` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`last_used` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL
) ENGINE = MyISAM CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
表名 | 作用 |
---|---|
users | 存储用户信息表 |
persistent_logins | 存储用户登录token有效时间表 |
3.修改application.yml
不在配置spring security的用户名和密码
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
password: 123
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
jpa:
show-sql: true
database: mysql
hibernate:
ddl-auto: update
open-in-view: true
4.准备pojo
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Table(name="users")
public class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
}
@Data
@ToString
@NoArgsConstructor
public class ImageCode {
//验证码
private String code;
//过期时间
private LocalDateTime expireTime;
//图片
private BufferedImage image;
public ImageCode(String code, int expireTime, BufferedImage image) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
this.image = image;
}
public boolean isExpried() {
return LocalDateTime.now().isAfter(expireTime);
}
}
5.准备自定义的HTML
login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h2>标准登录页面</h2>
<h3>表单登录</h3>
<form th:action="@{/login}" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td>图形验证码</td>
<td><input type="text" name="imageCode"><img src="/code/image" alt="图形验证码"></td>
</tr>
<tr>
<td>记住我:
<!--name必须是remember-me-->
<td><input type="checkbox" name="remember-me" id=""></td>
</tr>
<tr>
<td colspan="2">
<button type="submit">登录</button>
</td>
</tr>
</table>
</form>
</body>
</html>
logout.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>退出登录</title>
</head>
<body>
<a href="/logout">退出登录</a>
</body>
</html>
success.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
登录成功!
</body>
</html>
failure.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
登录失败!
</body>
</html>
40x.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>403</title>
</head>
<body>
对不起,你没有权限访问!
</body>
</html>
6. 编写接口
public interface UserDao extends JpaRepository<User,Long>, JpaSpecificationExecutor<User> {
}
7.编写配置
webMvcConfig:html页面存放地址,需要进行跨域,所以需要配置开放前端访问地址。
SpringSecurityConfig:主体实现功能,报错页面403,该页面会提示你没有权限访问;记住登录信息,该功能实现了数据库持久化,主要将用户登录详细信息,token存放在persistent_logins表中;验证码的接口调用;以及授权和相关配置处理。
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 开放前端访问地址
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/").setViewName("login");
// registry.addViewController("/login").setViewName("login");
// registry.addViewController("/index").setViewName("login");
registry.addViewController("/success").setViewName("success");
registry.addViewController("/failure").setViewName("failure");
}
}
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
private static final String USERNAME = "user";
private static final String PASSWORD = "123456";
@Autowired
@Qualifier(value = "userDetailsService")
private UserDetailsServiceImpl userDetailsService;
@Autowired
private DataSource dataSource; // 数据源
@Autowired
private ValidateCodeFilter validateCodeFilter;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private MyLogoutSuccessHandler myLogoutSuccessHandler;
/**
* 持久化token
* <p>
* Security中,默认是使用PersistentTokenRepository的子类InMemoryTokenRepositoryImpl,将token放在内存中
* 如果使用JdbcTokenRepositoryImpl,会创建表persistent_logins,将token持久化到数据库
*
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource); // // 设置数据源
//自动创建表,第一次执行会创建,以后执行需要注释掉
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
* 支持 password 模式
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 密码加密算法
*
* @return
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 授权验证服务(模拟没有用户详细信息实现类验证)
*
* @param auth
* @throws Exception
*/
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// // 加密(SHA-256+随机盐+密钥)
// String password = encoder.encode(PASSWORD);
// // 验证管理员身份
// auth.inMemoryAuthentication().withUser(USERNAME).password(password).roles("ADMIN");
// }
/**
* 授权验证服务(通过业务层验证后返回用户详细信息)
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//
validateCodeFilter.setMyAuthenticationFailureHandler(myAuthenticationFailureHandler);
//自定义403页面
http.exceptionHandling().accessDeniedPage("/403");
//添加退出的映射地址
http.logout() // 退出登录相关配置
.logoutUrl("/logout") // 自定义退出登录页面
// .logoutSuccessHandler(myLogoutSuccessHandler) //退出成功后要做的操作(如记录日志),和logoutSuccessUrl互斥
// .logoutSuccessUrl("/login") // 退出成功后跳转页面
.deleteCookies("JSESSIONID") //退出时要删除的Cookies的名字
.permitAll();
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) //
// 加载用户名密码过滤器的前面,调用自定义验证码过滤器
.formLogin() // 表单登录
.loginPage("/login.html") //登录页面设置
.usernameParameter("username").passwordParameter("password").loginProcessingUrl("/login")//登录访问的路径
.defaultSuccessUrl("/success") //登录成功,跳转的路径
.failureUrl("/failure") // 失败的路径
// .successHandler(myAuthenticationSuccessHandler) // 成功处理
// .failureHandler(myAuthenticationFailureHandler) // 失败处理
.permitAll() //指定路径,无需保护
.and().authorizeRequests() // 身份认证设置
.antMatchers("/", "/login", "/code/image") // 此路径不需要身份认证
.permitAll().anyRequest() //其他路径,需要认证
.authenticated().and().csrf().disable();// 关闭CSRF(防止跨站脚本攻击的csrf token)
// 开启记住我功能,并设置记住我为一周
http.rememberMe().tokenRepository(persistentTokenRepository()) // 设置数据访问层
.userDetailsService(userDetailsService).tokenValiditySeconds(7 * 24 * 60 * 60);
}
/**
* 开放资源文件
* @param web
*/
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/css/**", "/js/**", "/plugins/**", "/images/**", "/fonts" + "/**");
}
}
8.编写验证码过滤器
主要用于验证验证码是否正确,正确则通过,不正确则将异常抛出,由最上层MyAuthenticationFailureHandler捕获,返回相应的错误信息。
@Slf4j
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
public MyAuthenticationFailureHandler getMyAuthenticationFailureHandler() {
return myAuthenticationFailureHandler;
}
public void setMyAuthenticationFailureHandler(MyAuthenticationFailureHandler myAuthenticationFailureHandler) {
this.myAuthenticationFailureHandler = myAuthenticationFailureHandler;
}
/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 确保请求只通过一次filter
* @param request
* @param response
* @param filterChain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 只有当处理登录功能时才执行
if (StringUtils.equals("/login", request.getRequestURI()) && StringUtils.equalsIgnoreCase(request.getMethod(),
"post")) {
// 最好做try catch 处理异常
try {
validate(new ServletWebRequest(request));
} catch (AuthenticationException e) {
myAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
// 执行验证码逻辑
private void validate(ServletWebRequest request) throws AuthenticationException, ServletRequestBindingException {
// 取出session中的验证码对象
ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request,
ValidateCodeController.SESSION_KEY);
// 取出表单提交中的验证码信息
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("code in request is null");
}
if (codeInSession == null) {
throw new ValidateCodeException("code in session is null");
}
if (codeInSession.isExpried()) {
sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
throw new ValidateCodeException("code has expreied");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("code is not right");
}
// 验证通过,清除session中的验证码信息
sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
}
}
@Component
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
log.info(String.format("IP %s 于 %s 尝试登录系统失败,失败原因:%s", request.getRemoteHost(), LocalDateTime.now(), exception.getMessage()));
// 这里处理登录失败后就会输出错误信息
response.setCharacterEncoding("UTF-8"); // 设置编码格式
response.setContentType("application/json"); // 设置数据格式
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("status", 401);
if (exception instanceof LockedException) {
map.put("msg", "账户被锁定,登录失败!");
} else if (exception instanceof BadCredentialsException) {
map.put("msg", "用户名或密码输入错误,登录失败!");
} else if (exception instanceof DisabledException) {
map.put("msg", "账户被禁用,登录失败!");
} else if (exception instanceof AccountExpiredException) {
map.put("msg", "账户过期,登录失败!");
} else if (exception instanceof CredentialsContainer) {
map.put("msg", "密码过期,登录失败");
} else {
map.put("msg", "登录失败!");
}
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
}
}
9.编写用户业务类
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
/**
* 用户密码加密器(这里应该是注入dao或mapper来实现业务逻辑操作)
*/
@Autowired
private PasswordEncoder passwordEncoder;
private static final String ADMIN = "admin";
private static final String PASSWORD = "123456";
@Autowired
private UserDao userDao;
/**
* 鉴权服务
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 创建一个构造器,该对象只有用户名
User userBuild = User.builder().username(username).build();
// 创建一个用户对象实例
Example<User> userExample = Example.of(userBuild);
// 根据用户名查询可用的用户对象
Optional<User> userOptional = userDao.findOne(userExample);
// 可用的用户对象是否存在
User user = userOptional.orElseThrow(() -> new UsernameNotFoundException("用户不存在或密码错误!"));
// 授权集合
// List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN");
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("sys.test,sys:add");
// 返回一个用户详细信息
return new org.springframework.security.core.userdetails.User(username,user.getPassword()
,true // 账户可用
,true // 账户是否过期
,true // 密码是否过期
,true // 账户是否被锁定
,authorities);
}
/**
* 鉴权服务(模拟没有业务层鉴权)
*
* @param username 用户名
* @return
* @throws UsernameNotFoundException
*/
// @Override
// public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// if (!ADMIN.equals(username)) {
// throw new UsernameNotFoundException("用户不存在或密码错误!");
// }
// // 这里的密码应该从数据库中取出
// String password = passwordEncoder.encode(PASSWORD);
// // 授予admin用户权限
// Collection<? extends GrantedAuthority> authorities =
// Stream.of(new SimpleGrantedAuthority("ADMIN")).collect(Collectors.toList());
// // 返回一个用户对象
// User user = new User(username, password, authorities);
// return user;
// }
}
10.编写剩余的handler
@Component
@Slf4j
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
log.info(String.format("IP %s,用户 %s, 于 %s 成功登录。", request.getRemoteHost(), authentication.getName(),
LocalDateTime.now()));
//这里处理登录成功后就会json输出authentication里面的数据
response.setCharacterEncoding("UTF-8"); // 设置编码格式
response.setContentType("application/json"); // 设置数据格式
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("status", 200);
map.put("msg", authentication.getPrincipal()); // 登录成功的对象
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
}
}
@Component
@Slf4j
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
log.info(String.format("IP %s,用户 %s, 于 %s 注销登录。", request.getRemoteHost(), authentication.getName(),
LocalDateTime.now()));
response.setCharacterEncoding("UTF-8"); // 设置编码格式
response.setContentType("application/json"); // 设置数据格式
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("status", 200);
map.put("msg", "注销登录成功!");
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
}
}
11.编写验证码控制器
@RestController
public class ValidateCodeController {
public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
// 操作session的工具类
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ImageCode imageCode = createImageCode(request);
// 将验证码存入session
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
// 输出图片
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}
// 创建验证码对象
private ImageCode createImageCode(HttpServletRequest request) {
int width = 67;
int height = 23;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
Random random = new Random();
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}
String sRand = "";
for (int i = 0; i < 4; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(rand, 13 * i + 6, 16);
}
g.dispose();
return new ImageCode(sRand, 60, image);
}
/**
* 生成随机背景条纹
*
* @param fc
* @param bc
* @return
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
12.测试
访问 http://localhost:8080/ 进行登录验证,用户名:admin,密码:123456
具体演示,自行测试。