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、自定义数据库模型
用户过期试验
参考文档
本文使用项目:
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
<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 任意登录用户都可以访问
@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 接口。
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() 函数的 过期日志:来自博客园
可是,怎么阻止过期用户继续操作啊?!
》》》全文完《《《来自博客园
补充:
public interface UserDetails extends Serializable
其下的User类
public class User implements UserDetails, CredentialsContainer {
public interface UserDetailsService
后记:
密码没有加密啊?
阻止过期用户继续访问啊?来自博客园
记住用户?记住用户多长时间?
登录过程中都做了什么?过滤器、拦截器啥的?
自动登录呢?
基于token的登录呢?
……
看来,还要搞更多试验、更多学习才是啊!
后面再写一篇好了。来自博客园
参考文档
1、《Spring Security实战》
书,作者:陈木鑫,2019年8月第1版
非常感谢。
2、