背景
本系列教程,是作为团队内部的培训资料准备的。主要以实验的方式来体验SpringSecurity
的各项Feature。
实验0:Hello SpringSecurity
第一步,新建一个SpringBoot
项目,起名:springboot-security
,核心依赖为Web
,此处先不引入SpringSecurity
依赖。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
建好项目后,创建一个简单的HelloController.java,包含一个/hello
的GET
请求:
@RestController public class HelloController { @GetMapping("/hello") public String hello(){ return "hello springsecurity"; } }
好了,这时直接启动项目,在浏览器访问:localhost:8080/hello
返回hello springsecurity
,表明接口正常。
实验1:默认用户名与密码
pom文件中引入SpringSecurity
依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
然后不做其他任何改动,直接重启项目。
在浏览器访问:localhost:8080/hello
,这次,接口返回302,跳转到了http://localhost:8080/login
,要求我们输入用户名与密码完成登录。可问题是,这里的账号密码分别是多少呢?
仔细观察日志,在控制台的启动日志中有如下一行:
Using generated security password: 26420b20-8ab1-421a-968b-2c537e420527
这表明SpringSecurity
为我们生成了一个UUID
形式的密码,默认的用户名为user
;输入用户名与密码,成功登录后,可以正常访问/hello
。
因此,仅引入SpringSecurity
依赖,就实现了对我们后端接口的防护,这便是使用框架的意义:简单、直接、有效。
问题来了,鬼知道它的默认用户名是user
。。这个可用从官网docs.spring.io/spring-secu…或者SpringBoot
自动配置类的源码查到。
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.springframework.boot.autoconfigure.security; import ... @ConfigurationProperties( prefix = "spring.security" ) public class SecurityProperties { public static final int BASIC_AUTH_ORDER = 2147483642; public static final int IGNORED_ORDER = -2147483648; public static final int DEFAULT_FILTER_ORDER = -100; private final SecurityProperties.Filter filter = new SecurityProperties.Filter(); private final SecurityProperties.User user = new SecurityProperties.User(); public SecurityProperties() { } public SecurityProperties.User getUser() { return this.user; } public SecurityProperties.Filter getFilter() { return this.filter; } public static class User { private String name = "user"; private String password = UUID.randomUUID().toString(); private List<String> roles = new ArrayList(); private boolean passwordGenerated = true; ... } ... }
Note: 最开始引入了devtools
依赖,目的是能够在配置、代码更新时,能够热启动项目,不用每次都手动停止,再启动,不过在Idea
中需要进行如下两步,完成配置:
Ctrl+Shift+Alt+/
打开maintenance
面板,选择第一个registry
, 勾选compiler.automake.allow.when.app.running
保存;
Ctrl+Alt+S
, 打开配置面板,在File | Settings | Build, Execution, Deployment | Compiler下勾选Build project automatically
;
实验2:配置文件中覆盖默认的用户名与密码
我们知道,在application.properties
或者application.yml
中,可以进行一些自定义配置,对于SpringSecurity
也不例外。这里以application.properties
为例,在其中添加如下配置:
spring.security.user.name=ok spring.security.user.password=000
以上配置用户名为ok,密码为000;为了直接看到效果,我们在浏览器中清除本站的Cookie,或者访问SpringSecurity为我们提供的/logout
端点,退出登录。
输入实验1中的用户名user
,密码26420b20-8ab1-421a-968b-2c537e420527
,发现无法完成登录; 输入application.properties
中配置的用户名ok
,密码000
,登录成功,并实现对/hello
端点的访问。
Note:其实,还可以通过UserDetailsManager
或InMemoryUserDetailsManager
在内存中配置用户名与密码,实现对配置文件中账号信息的覆盖。
实验3:内存中覆盖默认的用户名与密码
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public UserDetailsService userDetailsService(){ InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("dev").password("123").authorities("p1").build()); return manager; } }
上面这段代码干了这样几件事:
- 自定义配置类
SecurityConfig
,继承自WebSecurityConfigurerAdapter
;
- 定义一个Bean:userDetailsService,其中创建并返回了一个in-memory的用户;
InMemoryUserDetailsManager
创建用户时,指定了用户名、密码、权限(或角色);
重启项目,浏览器访问:localhost:8080/hello
,跳转到http://localhost:8080/login
;键入用户名dev
,密码123
,后台报错了:没有密码编码器。。
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
作为一个安全框架,SpringSecurity还是很有原则的,要求使用者必须对密码进行加密,因为数据泄露的事件在历史上也是多次发生了。。而且明文存储密码,还可能被偷窥;一旦持久化的数据库暴露,会引发一系列的其他连环事故:比如撞库(讲真,大多数人为了方便记忆,在各网站、平台的密码都是相同的,大家都是肉体凡胎,谁又能记住不同的密码呢),怕了怕了。
解决方法1:我们修改配置,定义一个Bean,返回一个PasswordEncoder
实例,
@Bean public PasswordEncoder passwordEncoder(){ return NoOpPasswordEncoder.getInstance(); // Deprecated }
Note:不过上面这个Bean中的NoOpPasswordEncoder
被标记为Deprecated
,并且从注释中可以看到,建议这种密码编码器仅作为遗留项目或测试使用,因为作为一个密码编码器,它的matches
方法其实什么也没干⊙︿⊙
/** * This {@link PasswordEncoder} is provided for legacy and testing purposes only and is * not considered secure. * * A password encoder that does nothing. Useful for testing where working with plain text * passwords may be preferred. * * @author Keith Donald * @deprecated This PasswordEncoder is not secure. Instead use an adaptive one way * function like BCryptPasswordEncoder, Pbkdf2PasswordEncoder, or SCryptPasswordEncoder. * Even better use {@link DelegatingPasswordEncoder} which supports password upgrades. * There are no plans to remove this support. It is deprecated to indicate that this is a * legacy implementation and using it is considered insecure. */ @Deprecated public final class NoOpPasswordEncoder implements PasswordEncoder { private static final PasswordEncoder INSTANCE = new NoOpPasswordEncoder(); private NoOpPasswordEncoder() { } @Override public String encode(CharSequence rawPassword) { return rawPassword.toString(); } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return rawPassword.toString().equals(encodedPassword); } /** * Get the singleton {@link NoOpPasswordEncoder}. */ public static PasswordEncoder getInstance() { return INSTANCE; } }
解决方法2:这里我们直接在密码处进行编码:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public UserDetailsService userDetailsService(){ InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); manager.createUser(User.withUsername("dev").password(encoder.encode("123")).authorities("p1").build()); return manager; } }
上述代码增加了编码器,通过PasswordEncoderFactories
的工厂方法创建一个PasswordEncoder
实例,实现密码加密; 再次登录,输入用户名dev
, 密码123
即可,实现对/hello
端点的访问。
实验4:密码编码器的默认加密方式
@Configuration @Slf4j public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public UserDetailsService userDetailsService(){ InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); log.info("Password: {}", encoder.encode("123")); manager.createUser(User.withUsername("dev").password(encoder.encode("123")).authorities("p1").build()); return manager; } }
上面代码增加了一行输出,打印加密后的密码:Password: {bcrypt}$2a$10$4wUnbQvRHsxNuD1MTxdhru7GPANsBh/0Y37fepAduOGQsmtQrMjs.
,可以看到输出的密码前有个特殊说明{bcrypt},表明密码的加密方式为bcrypt
,也可以从PasswordEncoderFactories
类中查看默认的密码加密方式为bcrypt
。bcrypt
密码结构见下图:
public final class PasswordEncoderFactories { private PasswordEncoderFactories() { } @SuppressWarnings("deprecation") public static PasswordEncoder createDelegatingPasswordEncoder() { String encodingId = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put(encodingId, new BCryptPasswordEncoder()); encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder()); encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder()); encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5")); encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); encoders.put("scrypt", new SCryptPasswordEncoder()); encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", new Argon2PasswordEncoder()); return new DelegatingPasswordEncoder(encodingId, encoders); } }
既然能够作为SpringSecurity
默认的密码加密方式,bcrypt
这种加密方法应当是足够安全的,关于bcrypt
与其他加密算法对比的更多信息可参阅这篇10年前的文章:codahale.com/how-to-safe…