简单配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.3.12.RELEASE</version>
</dependency>
@Controller
public class UserController {
@GetMapping("/test")
@ResponseBody
public String test(){
return "hello";
}
}
注意:导入依赖之后,访问localhost:8080请求地址会自动跳转到/login中,出现一个表单,用户名为user,密码在控制台出现
将表单请求转换为弹出框请求
/**
* 定制用户认证管理器来实现用户认证
* 1. 提供用户认证所需信息(用户名、密码、当前用户的资源权)
* 2. 可采用内存存储方式,也可能采用数据库方式
*/
void configure(AuthenticationManagerBuilder auth);
/**
* 定制基于 HTTP 请求的用户访问控制
* 1. 配置拦截的哪一些资源
* 2. 配置资源所对应的角色权限
* 3. 定义认证方式:HttpBasic、HttpForm
* 4. 定制登录页面、登录请求地址、错误处理方式
* 5. 自定义 Spring Security 过滤器等
*/
void configure(HttpSecurity http);
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
void configure(WebSecurity web);
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* fromLogin():表单认证
* httpBasic():弹出框认证
* authorizeRequests():身份认证请求
* anyRequest():所有请求
* authenticated():身份认证
*/
http.httpBasic()
.and()
.authorizeRequests()
// 其它任何请求访问都需要先通过认证
.anyRequest()
.authenticated();
}
}
访问地址,例如:localhost:8080/hello
自定义用户信息
配置文件自定义用户名和密码
spring:
security:
user:
name: root #通过配置文件,设置静态用户名
password: root #配置文件,设置静态登录密码
基于内存存储认证信息
- 在 Spring Security 5.0 版本前,加密的 PasswordEncoder 接口默认实现类为 NoOpPasswordEncoder ,这个是可以不用加密的,直接使用明文密码存储。当前已经标注过时了。
- 在 Spring Security 5.0 版本后 ,默认实现类改为了 DelegatingPasswordEncoder ,这个实现类要求我们必须对加密后存储。
@Configuration
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 密码编码器,密码不能明文存储
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 设置默认的加密方式,使用 BCryptPasswordEncoder 密码编码器,
// 该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* fromLogin():表单认证
* httpBasic():弹出框认证
* authorizeRequests():身份认证请求
* anyRequest():所有请求
* authenticated():身份认证
*/
http.httpBasic()
.and()
.authorizeRequests()
// 其它任何请求访问都需要先通过认证
.anyRequest()
.authenticated();
}
/**
* 认证管理器:
* 1、认证信息提供方式(用户名、密码、当前用户的资源权限)
* 2、可采用内存存储方式,也可能采用数据库方式等
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 用户信息存储在内存中
String password = passwordEncoder().encode("1234");
logger.info("加密之后存储的密码:" + password);
auth.inMemoryAuthentication()
.withUser("admin")
.password(password)
.authorities("ADMIN");
}
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源的访问不需要拦截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
UserDetailsService自定义登录请求
在实际开发中,Spring Security应该动态的从数据库中获取信息进行自定义身份认证,采用数据库方式进行身份认证一般需要实现两个核心接口 UserDetailsService 和 UserDetails
UserDetailService接口
该接口只有一个方法 loadUserByUsername(),用于定义从数据库中获取指定用户信息的逻辑。如果未获取到用户信息,则需要手动抛出 UsernameNotFoundException 异常;如果获取到用户信息,则将该用户信息封装到 UserDetails 接口的实现类中并返回
public interface UserDetailsService {
// 输入参数 username 是前端传入的用户名
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
UserDetails接口
UserDetails接口定义了用于描述用户信息的方法
public interface UserDetails extends Serializable {
// 返回用户权限集合
Collection<? extends GrantedAuthority> getAuthorities();
// 返回用户的密码
String getPassword();
// 返回用户的用户名
String getUsername();
// 账户是否未过期(true 未过期, false 过期)
boolean isAccountNonExpired();
// 账户是否未锁定(true 未锁定, false 锁定)
// 用户账户可能会被封锁,达到一定要求可恢复
boolean isAccountNonLocked();
// 密码是否未过期(true 未过期, false 过期)
// 一些安全级别高的系统,可能要求 30 天更换一次密码
boolean isCredentialsNonExpired();
// 账户是否可用(true 可用, false 不可用)
// 系统一般不会真正的删除用户信息,而是假删除,通过一个状态码标志用户是否被删除
boolean isEnabled();
}
用户登录逻辑处理
@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {
/**
* Spring Security接收login请求调用UserDetailService这个接口中的loadUserByUsername
* loadUserByUsername根据传进来的用户名进行校验工作,
* 最后将查询到的用户信息封装到UserDetails这个接口的实现类中
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.info("登录用户名:{}",s);
//根据用户名查询用户数据
return new User(s,"123456", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
在使用了Spring Security-5.x版本,需要手动提供一个PasswordEncoder实现类,进行密码校验,PasswordEncoder是SpringSecurity的密码解析器,用户密码校验、加密,自定义登录逻辑时要求必须给容器注入PasswordEncoder的bean对象
@Component
public class PasswordEncoderImpl implements PasswordEncoder {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
}
@Configuration
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private UserDetailsService userDetailsService;
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* fromLogin():表单认证
* httpBasic():弹出框认证
* authorizeRequests():身份认证请求
* anyRequest():所有请求
* authenticated():身份认证
*/
http.httpBasic()
.and()
.authorizeRequests()
// 其它任何请求访问都需要先通过认证
.anyRequest()
.authenticated();
}
/**
* 认证管理器:
* 1、认证信息提供方式(用户名、密码、当前用户的资源权限)
* 2、可采用内存存储方式,也可能采用数据库方式等
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
}
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源的访问不需要拦截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
注意
上面自定义的密码解析器密码加密后与原来的一致,如果使用其他的密码解析器密码加密后与原来的不一致时,采用以下方式:
/**
* 密码编码器,密码不能明文存储
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
}
@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Resource
private PasswordEncoder passwordEncoder;
/**
* Spring Security接收login请求调用UserDetailService这个接口中的loadUserByUsername
* loadUserByUsername根据传进来的用户名进行校验工作,
* 最后将查询到的用户信息封装到UserDetails这个接口的实现类中
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.info("登录用户名:{}",s);
//根据用户名查询用户数据
return new User(s, passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
自定义返回UserDetails信息
public class AccountUser implements UserDetails {
private Long userId;
private static final long serialVersionUID = 540L;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
public AccountUser(Long userId, String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(userId, username, password, true, true, true, true, authorities);
}
public AccountUser(Long userId, String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
Assert.isTrue(username != null && !"".equals(username) && password != null, "Cannot pass null or empty values to constructor");
this.userId = userId;
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Resource
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("admin".equals(username)) {
return new AccountUser(12L, "admin", passwordEncoder.encode("123"), AuthorityUtils.NO_AUTHORITIES);
}
return null;
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private UserDetailServiceImpl userDetailService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.and()
.authorizeRequests()
.anyRequest()
.authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
PasswordEncoder(密码解析器)
工作流程
public interface PasswordEncoder {
//加密
String encode(CharSequence var1);
//比较密码
boolean matches(CharSequence var1, String var2);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
DaoAuthenticationProvider在additionalAuthenticationChecks方法中会获取Spring容器中的PasswordEncoder来对用户输入的密码进行比较
BCryptPasswordEncoder密码匹配流程
/**
* 参数一:原密码,参数二:加密后保存在数据库的密码
*/
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
从数据库得到的“密码”(参数: salt )进行一系列校验(长度校验等)并截取“密码”中相应的密码盐,利用这个密码盐进行同样的一系列计算 Hash 操作和 Base64 编码拼接一些标识符生成所谓的“密码”,最后 equalsNoEarlyReturn 方法对同一个密码盐生成的两个“密码”进行匹配
- 每次使用 BCryptPasswordEncoder 编码同一个密码都是不一样的,因为用到的随机密码盐每次都是不一样的,同一个密码和不同的密码盐组合计算出来的 Hash 值肯定不一样
- BCryptPasswordEncoder 编码同一个密码后结果都不一样,怎么进行匹配?因为密码盐是随机生成的,但是可以根据数据库查询出来的“密码”拿到密码盐,同一个密码盐+原密码计算 Hash 结果值是能匹配的
自定义密码解析器
/**
* 凭证匹配器,用于做认证流程的凭证校验使用的类型
* 其中有2个核心方法
* 1. encode - 把明文密码,加密成密文密码
* 2. matches - 校验明文和密文是否匹配
* */
public class MyMD5PasswordEncoder implements PasswordEncoder {
/**
* 加密
* @param charSequence 明文字符串
*/
@Override
public String encode(CharSequence charSequence) {
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
return toHexString(digest.digest(charSequence.toString().getBytes()));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return "";
}
}
/**
* 密码校验
* @param charSequence 明文,页面收集密码
* @param s 密文 ,数据库中存放密码
*/
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(encode(charSequence));
}
/**
* @param tmp 转16进制字节数组
* @return 饭回16进制字符串
*/
private String toHexString(byte [] tmp){
StringBuilder builder = new StringBuilder();
for (byte b :tmp){
String s = Integer.toHexString(b & 0xFF);
if (s.length()==1){
builder.append("0");
}
builder.append(s);
}
return builder.toString();
}
}
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
//return new BCryptPasswordEncoder(); //自带的
return new MyMD5PasswordEncoder();
}
}
@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
String password = passwordEncoder.encode("123");
log.info("登录用户:{},密码:{}", s, password);
return new User(s, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
路径匹配器
PathMatcher
匹配规则
- /a:仅匹配路径/a
- /a/*:操作符会替换一个路径名。在这种情况下,它将匹配/a/b或/a/c,而不是/a/b/c
- /a/**:操作符会替换多个路径名。在这种情况下,/a以及/a/b和/a/b/c都是这个表达式的匹配项
- /a/{param}:这个表达式适用于具有给定路径参数的路径/a
- /a/{param:regex}:只有当参数的值与给定正则表达式匹配时,此表达式才应用于具有给定路径参数的路径/a
使用案例
- 单个请求无请求方法匹配
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
如果使用角色为“USER”的用户来访问“/hello_admin”端点,那么会出现禁止访问的情况,因为“/hello_admin”端点只有角色为“ADMIN”的用户才能访问
注意:没有被 MVC 匹配器所匹配的端点,其访问不受任何的限制,效果相当于如下所示的配置
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
.anyRequest().permitAll();
- 单个请求有请求方法匹配
如果一个 Controller 中存在两个路径完全一样的 HTTP 端点,可以把 HTTP 方法作为一个访问的维度进行控制
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello").authenticated()
.mvcMatchers(HttpMethod.GET, "/hello").permitAll()
.anyRequest().denyAll();
- 多个路径匹配
.mvcMatchers("/test/xiao","/test/giao","/test/a","/test/a/b").hasRole("ADMIN")
//可以简化为以下方式
.mvcMatchers("/test/**").hasRole("ADMIN")
- 带有路径变量匹配
@GetMapping("/product/{code}")
public String productCode(@PathVariable String code){
return code;
}
.mvcMatchers("/product/{code:^[0-9]*$}").permitAll()
此时调用端点,假设code=1234a,不符合全部都是数字,报401;然后再次调用端点,code=12345,发现调用通过
AntPathMatcher
Ant 匹配器的表现形式和使用方法与前面介绍的 MVC 匹配器非常相似
使用方法:
- antMatchers(String patterns)
- antMatchers(HttpMethod method)
- antMatchers(HttpMethod method, String patterns)
mvc与ant匹配器的区别
- antMatchers(“/secured”)仅仅匹配 /secured
- mvcMatchers(“/secured”)匹配 /secured 之余还匹配 /secured/,/secured.html,/secured.xyz
因此 mvcMatcher 更加通用且容错性更高
正则表达式
使用方法:
- regexMatchers(HttpMethod method, String regex)
- regexMatchers(String regex)
使用这一匹配器的主要优势在于它能够基于复杂的正则表达式对请求地址进行匹配,这是 MVC 匹配器和 Ant 匹配器无法实现的
//只有输入的请求是一个合法的邮箱地址才能允许访问
.regexMatchers("/email/{email:.*(.+@.+\\.com)}")