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版本哦,下载下来!
有PDF版本哦(网页版末尾的 /html5/ 改为 /pdf/),下载下来!
目录
本文使用项目:
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
添加依赖包 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即可。
小结,
上面的项目很简单,但有一定实用性了。
登录页: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是其中的静态方法。
测试结果:成功
前面的章节,只有一个用户。本章介绍多个用户的使用。
自定义一个 UserDetailsService Bean即可。
接口有很多实现类,其中:来自博客园
1)InMemoryUserDetailsManager 的用户数据 存储到 内存,重启后丢失
2)JdbcUserDetailsManager 的用户数据 存储到 数据库,比如,MySQL数据库
准备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时引入,没有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---写到这儿了---
在使用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() 函数的 过期日志:来自博客园
可是,怎么阻止过期用户继续操作啊?!
用户登录了,登录前后发生了什么?为何服务器知道浏览器登录了?是用哪个账户登录的呢?
这就涉及到 浏览器(客户端)的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、