spring boot项目14:安全-基础使用(1)

JAVA 8

Spring Boot 2.5.3

MySQL 5.7.21(单机)

---

 

授人以渔:

1、Spring Boot Reference Documentation

This document is also available as Multi-page HTML, Single page HTML and PDF.

有PDF版本哦,下载下来!

2、Spring Security Reference

PDF版本哦(网页版末尾的 /html5/ 改为 /pdf/),下载下来!

 

目录

1、安全初体验

2、自定义表单登录页

3、多用户、角色、认证

使用InMemoryUserDetailsManager

使用JdbcUserDetailsManager

4、自定义数据库模型

用户过期试验

5、安全之Session

参考文档

 

本文使用项目:

mysql-hello

Web项目,底层使用MySQL存储数据,默认端口30000。

 

MySQL配置——后面会用到:

数据库配置
#
# MySQL on Ubuntu
spring.datasource.url=jdbc:mysql://mylinux:3306/db_example?serverTimezone=Asia/Shanghai
spring.datasource.username=springuser
spring.datasource.password=ThePassword
#spring.datasource.driver-class-name =com.mysql.jdbc.Driver # This is deprecated
spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
# 打开使用过程中执行的SQL语句
spring.jpa.show-sql: true

 

1、安全初体验

添加依赖包 spring-boot-starter-security:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

包结构:

启动项目,此时,任何链接都不能访问。

启动日志:

Using generated security password 后面是 默认用户user的密码。

在浏览器中访问,弹出登录对话框:

登录页-源码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <title>Please sign in</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
  </head>
  <body>
     <div class="container">
      <form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
<input name="_csrf" type="hidden" value="ed3f49ac-647f-4a59-b2e3-b24498725774" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      </form>
</div>
</body></html>

源码里面有一个提交数据 /login 的表单——实现登录。

输入 user、日志中的密码,登录成功。

除了上面的 /login 实现登录,还有一个 /logout 端点实现 退出登录:

随机密码,而且存在日志里面,不好。配置下面的可以实现固定用户及密码:

# 安全
spring.security.user.name=lib
spring.security.user.password=123

再次启动,日志没有密码信息了。

浏览器登录,使用上面配置的 lib、123即可。

 

小结,

上面的项目很简单,但有一定实用性了

 

2、自定义表单登录页

登录页:login.html

static/login.html
<html>
<head>
	<title>login:mysql-hello</title>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
	<style>
		body {
			background: #ddd;
		}
	</style>
</head>
<body>
<div>请登录:</div>
<form action="login.html" method="post">
	<div>用户名:<input type="text" name="username" placeholder="用户名" /></div>
	<div>密码:<input type="password" name="password" placeholder="密码" /></div>
	<div><a href="#">忘记密码?</a></div>
	<div><input type="submit" value="登录" /> </div>
</form>
<br />
<br />
<div><a href="#">新用户注册</a></div>
</body>
</html>

注,包含username, password的<input>,注意<form>的action和method来自博客园

添加 AppWebSecurityConfig.java,继承 WebSecurityConfigurerAdapter 并重写 configure(HttpSecurity http)

@EnableWebSecurity
public class AppWebSecurityConfig extends WebSecurityConfigurerAdapter {

	/**
	 * 自定义登录页:login.html
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				// 自定义登录页
				.loginPage("/login.html")
				.permitAll()
				.and()
			.csrf().disable();
	}
}

登录页面:

输入前面配置文件中的用户名、密码,登录成功(首页没有建,显示status=404),但可以测试其它链接的。

 

指定处理登录的URL-未通过

在formLogin()下,指定处理登录的URL:

.formLogin()
	// 自定义登录页
	.loginPage("/login.html")
	// 处理登录请求的URL
	.loginProcessingUrl("/login")

但是,测试失败,登录未成功。

错误信息
浏览器页面:
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Sat Sep 04 23:03:14 CST 2021
There was an unexpected error (type=Method Not Allowed, status=405).
---

应用日志:
Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported]
Completed 405 METHOD_NOT_ALLOWED
"ERROR" dispatch for POST "/error", parameters={masked}

疑问

为什么呢?默认登录页的 action不就是 “/login” 吗?怎么这里配置了就不行呢?

像上面配置后,默认的/login 无效了?需要自己写?怎么写?格式呢?TODO

 

登录返回值

上面的试验中,登录成功后,跳转到首页。在真实的前后端分离系统中,登录后一般返回 成功与否的信息,比如,一段JSON数据,再由前端决定怎么处理——跳转到哪里。

在formLogin()下,配置 successHandler、failureHandler 分别实现登录成功、失败后的逻辑。来自博客园

.formLogin()
	// 自定义登录页
	.loginPage("/login.html")
	// 处理登录请求的URL
	// 指定后登录失败,注释掉,TODO
//				.loginProcessingUrl("/login")
	// 登录成功的处理
	.successHandler(new AuthenticationSuccessHandler() {
		
		@Override
		public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse resp,
				Authentication auth) throws IOException, ServletException {
			resp.setContentType("application/json;charset=utf-8");
			PrintWriter out = resp.getWriter();
			out.write(ResultVO.getSuccess("登录成功").toString());
		}
	})
	// 登录失败的处理
	.failureHandler(new AuthenticationFailureHandler() {
		
		@Override
		public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp,
				AuthenticationException ex) throws IOException, ServletException {
			resp.setContentType("application/json;charset=utf-8");
			resp.setStatus(HttpStatus.UNAUTHORIZED.value());
			PrintWriter out = resp.getWriter();
			out.write(ResultVO.getFailed(HttpStatus.UNAUTHORIZED.value(), "登录失败", "请重新登录").toString());
		}
	})
	.permitAll()
	.and()

 注,ResultVO 是项目的一个 统一返回对象类,getSuccess、getFailed是其中的静态方法

测试结果:成功

 

3、多用户、角色、认证

前面的章节,只有一个用户。本章介绍多个用户的使用。

自定义一个 UserDetailsService Bean即可。

接口有很多实现类,其中:来自博客园

1)InMemoryUserDetailsManager 的用户数据 存储到 内存,重启后丢失

2)JdbcUserDetailsManager 的用户数据 存储到 数据库,比如,MySQL数据库

 

使用InMemoryUserDetailsManager 

准备3个接口:

/security/admin/hello 需要ADMIN角色的用户才可以访问

/security/user/hello 需要USER角色的用户才可以访问

/security/app/hello 任意登录用户都可以访问

SecurityAdminController.java
@RestController
@RequestMapping(value="/security/admin")
@Slf4j
public class SecurityAdminController {

	@GetMapping(value="/hello")
	public String hello() {
		return "hello, Admin";
	}
	
}

其它两个Controller类似。来自博客园

 

更改 AppWebSecurityConfig:

之前的configure函数做了改动;

增加了 UserDetailsService Bean的生成函数,并增加了2个用户对应不同的角色;

passwordEncoder函数 在 本文使用的 S.B.版本是必须的,否则发生异常,,但这个NoOpPasswordEncoder过期了,,原因及解决方案有待进一步研究,TODO

	/**
	 * 试验2:资源授权
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.authorizeRequests()
				// 使用角色
				.antMatchers("/security/admin/**").hasRole("ADMIN")
				.antMatchers("/security/user/**").hasRole("USER")
				.antMatchers("/security/app/**").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin().permitAll()
				.and()
			.csrf().disable();
	}
	
	/**
	 * 基于内存数据库的用户信息
	 */
	@Bean
	public UserDetailsService userDetailsService() {
		// 基于内存的用户信息:2个用户,不同角色
		InMemoryUserDetailsManager man = new InMemoryUserDetailsManager();
		man.createUser(User.withUsername("user").password("123").roles("USER").build());
		man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		return man;
	}
	
	/**
	 * 必须有,否则发生异常
	 * 是否可以使用其它 PasswordEncoder 的实现类呢?
	 * 据说是 5.X版本之后默认启用了 委派密码编码器 导致
	 * @author ben
	 * @date 2021-09-05 00:10:49 CST
	 * @return
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		// 过时了?怎么弄?TODO
		// 因为不安全,只能用于测试、明文密码验证等,故废弃
		return NoOpPasswordEncoder.getInstance();
	}

注意,上面的配置后,配置文件中的 lib 用户就不能使用了

启动应用,测试:

user、admin分别访问前面的 3个接口。

用户/接口 user admin
/security/admin/hello type=Forbidden, status=403 hello, Admin
/security/user/hello hello, User type=Forbidden, status=403
/security/app/hello hello, APP hello, APP

符合预期。来自博客园

 

更进一步:

动态管理用户(增删改查),或可以使用 容器中的 userDetailsService Bean——即上面配置生成了。

转换为 InMemoryUserDetailsManager 后进行操作。

不过,应用重启后,这些用户数据丢失,意义不大,但从接口来看是可以做到的。

 

使用JdbcUserDetailsManager

引入:来自博客园

<!-- 使用JdbcUserDetailsManager时引入,没有JPA的吗? -->
<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>

注,本项目中,mysql-connector-java早已引入

在MySQL建立数据表:找到 JdbcUserDetailsManager 类 对应的jar包(spring-security-core),DDL文件位于 同一个jar包的 org.springframework.security.core.userdetails.jdbc.users.ddl

拷贝其中的语句,改其中的 varchar_ignorecase 为 varchar类型——MySQL支持。来自博客园

使用改造后的语句到MySQL终端去执行:下图展示执行成功,建立了两张表 users、authorities

改造 AppWebSecurityConfig 的userDetailsService函数:

	/**
	 * 使用JdbcUserDetailsManager
	 * 本应用的底层为 MySQL数据库——上面的dataSource
	 */
	@Bean
	public UserDetailsService userDetailsService() {
		JdbcUserDetailsManager man = new JdbcUserDetailsManager();
		System.out.println("dataSource=" + dataSource);
		man.setDataSource(dataSource);
		man.createUser(User.withUsername("user").password("123").roles("USER").build());
		man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		return man;
	}

测试 两个用户对前面3个接口的权限:测试成功,符合预期

注,上面的 dataSource是 HikariPool-1:

JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. 
Therefore, database queries may be performed during view rendering. Explicitly configure 
spring.jpa.open-in-view to disable this warning
dataSource=HikariDataSource (HikariPool-1)

启动后,新建数据表的数据:

注意,角色创建时是 user、admin,但在 数据库里面是 以“ROLE_”开头

 

再次启动应用,发生异常,启动失败,因为 user、admin在数据库中已经存在了。来自博客园

改造userDetailsService()函数:多了用户存在性判断

	@Bean
	public UserDetailsService userDetailsService() {
		JdbcUserDetailsManager man = new JdbcUserDetailsManager();
		man.setDataSource(dataSource);
		
		if (!man.userExists("user")) {
			man.createUser(User.withUsername("user").password("123").roles("USER").build());
		}
		if (!man.userExists("admin")) {
			man.createUser(User.withUsername("admin").password("123").roles("ADMIN").build());
		}
		return man;
	}

 

默认的数据库模型肯定无法满足生产的需求,比如,里面的密码都没有加密。

Spring Security具有优良的扩展性,可以很好地实现自定义的数据库模型。

 

---210905 01:55---写到这儿了---

 

4、自定义数据库模型

在使用JdbcUserDetailsManager的默认数据库模型时,用户、权限是分成两张表的。来自博客园

本章介绍 基于自定义数据库模型的认证和授权。

两个步骤:1)实现UserDetails——用户详情;2)实现UserDetailsService——用户详情服务(类似于前面的2个Manager);

cofigure函数保持不变。

 

AppUser类,用户实体类,也实现了 UserDetails 接口。

AppUser.java
package org.lib.mysqlhello.security.self;

import java.util.Collection;
import java.util.Date;
import java.util.List;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Transient;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;

/**
 * 自定义用户
 * @author ben
 * @date 2021-09-05 09:26:11 CST
 */
@Entity
@Data
@Slf4j
public class AppUser implements UserDetails {

	private static final long serialVersionUID = 210905L;

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(columnDefinition = "VARCHAR(50) NOT NULL UNIQUE")
	private String username;
	
	@Column(columnDefinition = "VARCHAR(384) NOT NULL")
	private String password;

	/**
	 * 用户角色
	 * 多个角色使用英文都好(,)隔开
	 */
	@Column(columnDefinition = "VARCHAR(500) NOT NULL")
	private String roles;
	
	/**
	 * 用户是否启用:默认启用
	 */
	@Column(columnDefinition = "BIT(1) DEFAULT true")
	private Boolean enabled;

	/**
	 * 有效期时间戳
	 * 默认为0 永久有效
	 */
	@Column(columnDefinition = "BIGINT DEFAULT 0")
	private Long expiration;
	
	/**
	 * 创建时间
	 */
	@Column(insertable = false, columnDefinition = "DATETIME DEFAULT NOW()")
	private Date createTime;
	
	/**
	 * 更新时间
	 */
	@Column(insertable = false, updatable = false, columnDefinition = "DATETIME DEFAULT NOW() ON UPDATE NOW()")
	private Date updateTime;

	// ----实现UserDetails接口----
	
	// set函数已使用 @Data 注解建立
	@Transient
	private List<GrantedAuthority> authorities;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return authorities;
	}

	@Override
	public boolean isAccountNonExpired() {
		if (this.expiration <= 0) {
			return true;
		}
		
		if (this.expiration >= System.currentTimeMillis()) {
			return true;
		}
		
		log.warn("用户过期:id={}, expiration={}", this.id, this.expiration);
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return this.enabled;
	}
	
	// ----实现UserDetails接口----
	
}

 

启动应用,数据表建好了:

插入两条数据(用户):

-- 和之前不同,admin有两个角色哦
insert into app_user(username, password, roles) values("admin", "123", "ROLE_ADMIN,ROLE_USER");
insert into app_user(username, password, roles) values("user", "123", "ROLE_USER");

 

AppUserDetailsService类:实现了 UserDetailsService接口,并使用 @Service注解。来自博客园

@Service
public class AppUserDetailsService implements UserDetailsService {

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		return null;
	}

}

上面的 AppUserDetailsService Bean 还无法使用:

前一章 的 userDetailsService() 函数也生成了 userDetailsService Bean,此时,虽然应用可以启动,但是,无法登录——因为有两个 userDetailsService Beans吧。

 

注释掉AppWebSecurityConfig类 的 userDetailsService() 函数。来自博客园

启动应用,登录:AppUserDetailsService 还没写完导致

继续改造 AppUserDetailsService...

改造后的 AppUserDetailsService:来自博客园

package org.lib.mysqlhello.security.self;

import java.util.Objects;
import java.util.function.Consumer;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * AppUserDetailsService
 * @author ben
 * @date 2021-09-05 10:34:23 CST
 */
@Service
public class AppUserDetailsService implements UserDetailsService {

	@Autowired
	private AppUserDAO appUserDao;
	
	private Consumer<Object> cs = System.out::println;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		AppUser user = appUserDao.findByUsername(username);
		cs.accept("user 1=" + user);
		if (Objects.isNull(user)) {
			throw new UsernameNotFoundException("用户不存在");
		}
		
		// 权限集
		// 使用Spring Security的AuthorityUtils:默认支持 英文逗号分开的权限集
		user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
		
		cs.accept("user 1=" + user);
		return user;
	}

}

 

启动应用,测试已添加的用户admin、user访问各个接口:成功,符合预期来自博客园

 

UsernameNotFoundException说明

继承了 AuthenticationException——其下有若干的异常。

 

用户过期试验

在AppUserDetailsService#loadUserByUsername函数中抛出用户过期异常

失败了

看来不是这么用的。来自博客园

记得 AppUser 实现 UserDetails接口 时,有一个 isAccountNonExpired() 函数,或许,过期的判断已经实现了。

设置user过期时间——30秒有效期:

-- 当前时间+30秒过期
-- 注意使用 (unix_timestamp(now())+30)*1000!
-- 最开始只使用 now() 时验证失败/sad
mysql> update  app_user set expiration=(unix_timestamp(now())+30)*1000 where id = 2;

在执行上面的语句后,启动应用,使用 user登录:登录成功。

30秒后继续操作,可以继续操作,没有被阻止。TODO

30秒后,在另一个浏览器重新登录:登录失败,提示账号过期。

回到之前已登录的浏览器操作:可以继续,但会输出 isAccountNonExpired() 函数的 过期日志:来自博客园

 

可是,怎么阻止过期用户继续操作啊?!

 

5、安全之Session

用户登录了,登录前后发生了什么?为何服务器知道浏览器登录了?是用哪个账户登录的呢?

这就涉及到 浏览器(客户端)的Cookie 和 服务器端的Session了。

 

启动服务器,访问任一页面,浏览器产生一个名为JSESSIONID的session(Google的Chrome浏览器,按F12查下控制台,选择Application下看Cookie):

登录后,这个session改变了:

之后其它操作时,浏览器都把这个JSESSIONID作为Cookie头发送到服务器:

服务器从这个Cookie头获取JSESSIONID信息,并以此判断(检查应用中存储的session)用户是否登录。

 

使用spring security后,启动时会建一个名为 springSecurityFilterChain 的Bean——安全过滤器链,其下存在13个安全过滤器:

[org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@130a99fe,
org.springframework.security.web.context.SecurityContextPersistenceFilter@59ebe484, 
org.springframework.security.web.header.HeaderWriterFilter@4458887d, 
org.springframework.security.web.authentication.logout.LogoutFilter@6c3830ed, 
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@2d2710a8, 
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@5122387, 
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@31773d5b, 
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1cafb30, 
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@719e8f9f, 
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@5bbf8daa, 
org.springframework.security.web.session.SessionManagementFilter@4c364a9d, 
org.springframework.security.web.access.ExceptionTranslationFilter@7a6078d, 
org.springframework.security.web.access.intercept.FilterSecurityInterceptor@1d642682]

其中就有一个 类型为 SessionManagementFilter 的——管理Session使用吗?

在SessionManagementFilter 类的 doFilter添加断点,调试可知 登录前后session管理 详情

 

启动应用后,首次访问,session为null:

之后执行 SessionManagementFilter 的:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

第一轮调试完毕,登录页被打开:此时,浏览器已经存在session了——上面的session一定是在 SessionManagementFilter 执行后的某一步建立的,TODO

输入用户名、密码登录,进入应用调试,此时,已经可以获取浏览器中的session了:

登录成功后,session没有发生变化。

注意

这里有一个问题,上面调试时session在登录页打开、登录后没有发生变化,但在非调试时,却是 变了的;

再次调试——使用F8加快进度,这时,session和正常情况一样,变了。TODO

 

除了安全的 springSecurityFilterChain Bean外,Web应用本身也有一些Filter,这些Filter有什么用?TODO

后面找到了 再补充吧!

 

关于 session ,spring boot可以使用 server.servlet.session.* 进行配置,包括设置 cookie 的有效时间等。

本文的session是单机版,服务启动后,session消失;同应用的其它实例无法共享……还需继续研究。

 

》》》全文完《《《来自博客园

 

补充:

public interface UserDetails extends Serializable

其下的User类

public class User implements UserDetails, CredentialsContainer {

public interface UserDetailsService

 

后记:

密码没有加密啊?

阻止过期用户继续访问啊?来自博客园

记住用户?记住用户多长时间?

登录过程中都做了什么?过滤器、拦截器啥的?

自动登录呢?

基于token的登录呢?

……

看来,还要搞更多试验、更多学习才是啊!

后面再写一篇好了。来自博客园

 

参考文档

1、《Spring Security实战》

书,作者:陈木鑫,2019年8月第1版

非常感谢。

2、

 

posted @ 2021-09-05 11:51  快乐的凡人721  阅读(260)  评论(0编辑  收藏  举报