1. 介绍
Spring Security 是一个安全管理框架,核心功能有两个:认证、授权
- 认证:判断 访问者 是不是系统里的用户,可以简单的认为能否登陆
- 比如:手机刷脸解锁
- 授权:判断 访问者 是否有权限做某个操作
- 比如:去京东买东西,上面的价格只能看,不能买,只有商家才能改
官网地址:https://spring.io/projects/spring-security
2. 快速入门
创建一个普通的maven项目
1. pom.xml
<!-- 引用父pom -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.13</version>
</parent>
<dependencies>
<!-- spring web 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
2. Application
@SpringBootApplication
public class Application {
public static void main(String[] args){
SpringApplication.run(Application.class,args);
}
}
3. Controller
@RestController
public class SecController {
@GetMapping("/sec")
public String sec(){
return "spring-secutiry";
}
}
启动项目,访问结果:
4. 引入 Security
修改 pom.xml 增加 Spring Security 依赖
<!-- spring security 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
然后重启项目,再次访问:localhsot:8080/sec,就需要登录了
默认的用户名:user,
密码:
注意,每次重启,密码都变
退出登陆,访问:http://localhost:8080/logout
3. 从数据库查询用户
上面的用户名、密码是框架自带的,正常情况下,我们的用户都是存储在数据库中
下面,从数据库中获取用户,完成登陆
1. pom.xml
增加 Mybatis Plus 和 mysql 依赖
<!-- mybatis plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- 连接MySQL数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
</dependency>
2. properties
#数据库
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false
spring.datasource.username=
spring.datasource.password=
3. 数据库
CREATE TABLE `user` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NULL,
`password` VARCHAR(255) NULL,
PRIMARY KEY (`id`));
INSERT INTO `test`.`user` (`id`, `username`, `password`) VALUES ('1', 'fengxiansheng', '{noop}123456');
4.代码
User 实体类
@TableName("user")
public class User {
@TableId
private Integer id;
//用户名
private String username;
//密码
private String password;
get、set 省略
}
UserMapper 接口
public interface UserMapper extends BaseMapper<User> {
}
Application
@SpringBootApplication
@MapperScan("com.feng.security.mapper")
public class Application {
public static void main(String[] args){
SpringApplication.run(Application.class,args);
}
}
UserService
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getByUserName(String username){
Map<String,Object> map = new HashMap<String, Object>();
map.put("username", username);
List<User> users = userMapper.selectByMap(map);
if(CollectionUtils.isEmpty(users)){
return null;
}
return users.get(0);
}
}
新建 LoginUserService,实现 UserDetailsService 接口,这样就可以从数据库查询用户
/**
* UserDetailsService 是 spring security 提供的一个类,目的是根据 username 获取用户
* 框架底层会自动调用 loadUserByUsername 方法,查询用户
*/
@Component
public class LoginUserService implements UserDetailsService {
@Autowired
private UserService userService;
/**
* 根据用户名查询用户对象
* @param username 前端传的用户名
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库查询用户
User user = userService.getByUserName(username);
if(user == null){
throw new UsernameNotFoundException("没有这个用户");
}
// 把用户信息封装到一个 UserDetails 对象中,UserDetails是一个接口,LoginUser实现了这个接口
LoginUser loginUser = new LoginUser();
loginUser.setUser(user);
return loginUser;
}
}
新建 LoginUser 类,实现 UserDetails 接口,用来封装 User 信息
/**
* UserDetails 是spring security提供的一个接口,目的是封装登录的用户
* 需要实现这个接口,复写其中的方法,框架会自动调用这些方法做一些处理
*/
public class LoginUser implements UserDetails {
private User user;
/**
* 返回用户密码
* @return
*/
@Override
public String getPassword() {
return user.getPassword();
}
/**
* 返回用户名
* @return
*/
@Override
public String getUsername() {
return user.getUsername();
}
/**
* 用户是否过期,可以根据用户的信息判断是否过期
* @return
* false 表示用户过期,不可登陆
* true 可以登陆
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 用户是否锁定,可以根据用户的信息判断是否锁定
* @return
* false 用户锁定,不可登陆
* true 没有锁定,可以登陆
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 用户密码是否过期
* @return
* false 表示过期,不可登陆
* true 没有过期,可以登陆
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 用户是否禁用
* @return
* false 用户禁用,不可登陆
* true 没有禁用,可以登陆
*/
@Override
public boolean isEnabled() {
return true;
}
/**
* 返回这个用户的权限列表,暂时先不管
* @return
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}
重启项目,然后故意输入错误的用户密码
再输入正确的,结果正常跳转
4. 密码加密
spring security 从数据库中拿到密码后,会跟前端传递的密码进行比较,密码一样才能登陆成功
正常情况下数据库中存储的密码都应该是密文,Spring Security拿到后会自行解密
但是,我们的密码是这样子的:{noop}123456,"{noop}"表示密码没有加密,这样 Spring Security 就不会对 123456进行解密,但是存储明文不合适。
Spring Security 提供的 PasswordEncoder 可以帮我们解决这个问题。
1. PasswordEncoder
这是一个接口,主要有2个方法
- encode(CharSequence rawPassword)
- 对字符串进行加密,返回密文
- matches(CharSequence rawPassword, String encodedPassword)
- 校验明文(rawPassword))是否跟 密文(encodedPassword)匹配
- 返回 true,表示匹配
主要实现类:
实现类有很多,我们使用 BCryptPasswordEncoder,对密码进行加密、解密
2. 修改密码
修改密码,使用 BCryptPasswordEncoder 加密,然后存到数据库
修改 UserService ,增加方法:updatePassword
public void updatePassword(String username, String passwrod){
User user = getByUserName(username);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// {bcrypt}:密码的加密方式,有了这个前缀,spring security 拿到密码后,就使用对应的方式解密
user.setPassword("{bcrypt}" + passwordEncoder.encode(passwrod));
userMapper.updateById(user);
}
修改 Controller
@Autowired
private UserService userService;
@GetMapping("/modify")
public String modify(@RequestParam String username, @RequestParam String password){
userService.updatePassword(username, password);
return "success";
}
浏览器访问:
数据库:
3. 优化
但是这样子数据库存储的密码还是有 “{bcrypt}” 这样的前缀,如果外人看见,就知道了我们的加密方式,安全性不好
解决:搞一个配置类,声明一个 _PasswordEncoder _类型的 bean
@Configuration
public class SecurityConfig {
/**
* 声明一个 PasswordEncoder ,
* 在 userservice 中注入使用
* 同时 spring security 自动使用这个解密
* 这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
修改 UserService
@Autowired
private PasswordEncoder passwordEncoder;
public void updatePassword(String username, String passwrod){
User user = getByUserName(username);
user.setPassword(passwordEncoder.encode(passwrod));
userMapper.updateById(user);
}
手动修改数据库,去掉:{bcrypt} 这个前缀
重启项目后,更新密码为 222222:
数据库:
5. 自定义登录页面
1. 准备登陆页面
里面的 login.html 就是我们的登陆页面
把 static 文件夹复制到 项目中的 resource 文件夹下
2. 修改 SecurityConfig
让 SecurityConfig 继承 WebSecurityConfigurerAdapter
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 声明一个 PasswordEncoder ,
* 在 userservice 中注入使用
* 同时 spring security 自动使用这个解密
* 这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源不用验证
web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()// 开始认证
.anyRequest().authenticated()// 任何请求都需要认证
.and()
// form 表单认证,登录页面,不需要认证
.formLogin().loginPage("/login.html").permitAll()
.and()
.csrf().disable();//关闭 csrf,后面讨论
}
}
上面代码 antMatchers 方法中 表达式采用了 Ant 风格的路径匹配符:
3. 重启项目
浏览器访问:http://localhost:8080/sec,就会自动跳转到我们到 login.html
输入用户名、密码就可登陆
6. 自定义登录接口
通过配置 loginProcessingUrl 自定义登录接口
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")// 指定登录的接口,也就是点击登录时,提交数据的接口
.permitAll()
.and()
.csrf().disable();
}
上面的配置了 /user/login,也就是我们的 form 表单需要把数据提交到这个接口,所以还要修改 login.html
然后重启项目,依然可以正常访问
7. 登录参数
默认情况下登录表单中的参数是 username 和 password,这个不能变。这是因为,spring security 在框架中定死的
如果要自己设置,需要:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")// 指定登录的接口,也就是点击登录时,提交数据的接口
.usernameParameter("name")//用户名
.passwordParameter("pass")//密码
.permitAll()
.and()
.csrf().disable();
}
同时也需要修改 login.html
8. 登录回调
1. 成功回调
在 Spring Security 中,登录成功后,重定向 URL 的方法有两个:defaultSuccessUrl、successForwardUrl
准备,先搞一个 index.html 作为首页,我们搞的简单一些
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Spring Security</title>
</head>
<body>
<h1>这里就是首页</h1>
</body>
</html>
把 index.html 放到项目中的 resource 文件夹下
1. defaultSuccessUrl
- defaultSuccessUrl(“/index.html”,false);
- 如果在浏览器中输入的登录地址,登录成功后,就直接跳转到对应地址
- 如果在浏览器中输入了其他地址,例如 http://localhost:8080/sec,因为没有登录,所以会重定向到登录页面,登录成功后,来到 /sec 页面
- 如果第二个参数设置为true,只要登录成功,就直接重定向到对应地址
修改代码:
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
.defaultSuccessUrl("/index.html",false)// 配置登陆成功后跳转
.permitAll()
重启项目后,浏览器访问:http://localhost:8080/login.html,登陆成功后
退出登录,浏览器访问:http://localhost:8080/sec,会跳转到登陆页面,再次登录:
修改代码,设置第二个参数为true:
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
.defaultSuccessUrl("/index.html",true)// 只要登陆成功,一定会跳转到/index.html
.permitAll()
重启项目,浏览器访问:http://localhost:8080/sec,会跳转到登陆页面,再次登录:
它还有一个重载的方法,没有第二个参数,其实内部调用的就是:defaultSuccessUrl(“/index”,false);
2. successForwardUrl
successForwardUrl 是转发到指定的地址,比如:
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
.successForwardUrl("/index")// 只要登陆成功,就转发到:/index
.permitAll()
新增一个 IndexController
@Controller
public class IndexController {
@PostMapping("/index")
public String sec(){
System.out.println("index");
return "redirect:/index.html";
}
}
演示:省略
2. 失败回调
与登录成功相似,登录失败也是有两个方法:
- failureUrl
- 登录失败之后,会发生重定向
- failureForwardUrl
- 登录失败之后会发生服务端转发
准备,先搞一个 fail.html 作为首页,我们搞的简单一些
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Spring Security</title>
</head>
<body>
<h1>登录失败</h1>
</body>
</html>
把 fail.html 放到项目中的 resource 文件夹下
1. failureUrl
修改代码:
http.authorizeRequests()
.antMatchers("/fail.html").permitAll() // fail.html 这个请求不需要认证
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
.defaultSuccessUrl("/index.html",false)
.failureUrl("/fail.html")// 登陆跳转到 fail.html
.permitAll()
.and()
.csrf().disable();
故意输入错误的密码,结果:
事实上,重定向到百度都没问题:.failureUrl(“http://www.baidu.com”)
2. failureForwardUrl
修改代码:
http.authorizeRequests()
.antMatchers("/fail.html").permitAll() // fail.html 这个请求不需要认证
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login.html")
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
.defaultSuccessUrl("/index.html",false)
.failureForwardUrl("/fail")// 转发到 /fail
.permitAll()
.and()
.csrf().disable();
修改 SecController ,增加:
@PostMapping("/fail")
public String fail(){
return "登陆失败";
}
结果:
放开断点
如果想转发到百度:
@PostMapping("/fail")
public String fail(HttpServletResponse response) {
return "redirect:http://www.baidu.com";
}
登录失败后的结果:
3. 注销登录
默认注销的 URL 是 /logout,也可以修改
http.logout().logoutUrl("/user/logout");
还有一个 logoutRequestMatcher 方法不仅可以修改注销 URL,还可以修改请求方式,
http.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout","POST"));
重启后,浏览器访问:http://localhost:8080/user/logout
这时候只能使用 postman 工具
上图中 Cookie 的来源:访问 http://localhost:8080/sec,登陆成功后,在下图中可以找到Cookie
注意:实际中,logoutRequestMatcher方法和 logoutUrl 任意设置一个即可
logoutSuccessUrl 设置注销成功后要跳转的页面
http.logout().logoutUrl("/user/logout")
// .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout","POST")) 关闭这种方式,太麻烦
.logoutSuccessUrl("/fail.html");// 退出后跳转到 fail.html
结果:
4. Remember Me
1. 介绍
Remember Me 即记住我,目的是让用户选择是否记住用户的登录状态。
当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中
目前我们的项目登录成功后,关闭浏览器,再次访问还需要登录
2. 实现
创建表:persistent_logins
create table persistent_logins (
username varchar(64) not null,
series varchar(64) primary key,
token varchar(64) not null,
last_used timestamp not null
)
修改 login.html,增加 “记住我” 复选框
<div class="">
<input type="checkbox" name="remember-me"> 记住我
</div>
修改 SecurityConfig,增加:
@Autowired
private LoginUserService loginUserService;
@Autowired
private DataSource dataSource;//javax.sql.DataSource包下的类
/**
* remember me 功能是基于token验证的,
* 这里是通过JdbcTokenRepositoryImpl把token存到persistent_logins表中
* @return
*/
@Bean
public JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl() {
JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
persistentTokenRepository.setDataSource(dataSource);
return persistentTokenRepository;
}
//configure 方法 增加
http.rememberMe()
.tokenRepository(jdbcTokenRepositoryImpl()) // 配置token持久化仓库
// .tokenValiditySeconds(3600) // 过期时间,单位为秒
.userDetailsService(loginUserService); // 处理自动登录逻辑
浏览器登录时,选择“记住我”
登录成功后,数据库中就已经存储token
这样关闭浏览器后,再次访问就不用登录了
9. 前后端分离
之前功能都是在前后端不分离的情况下,也就是前端的代码在我们后端的项目中
但实际工作中,大多数都是前后端分离的,这样的开发架构下,前后端的交互都是通过 JSON 来进行交互
准备,pom.xml中添加
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.60</version>
</dependency>
1. 登陆成功返回json
修改 SecurityConfig
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/fail.html").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginProcessingUrl("/user/login")
.usernameParameter("name")
.passwordParameter("pass")
// 登录成功后设置json格式的返回结果
.successHandler(new AuthenticationSuccessHandler() {
/**
*
* @param request
* @param response
* @param authentication 保存了登录成功的用户信息
* @throws IOException
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(JSONObject.toJSONString(authentication));
out.flush();
out.close();
}
})
// 登录失败后设置json格式的返回结果
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
Map<String,String> map = new HashMap<>();
map.put("errMsg", exception.getMessage());
out.write(JSONObject.toJSONString(map));
out.flush();
out.close();
}
})
.permitAll()
.and()
.csrf().disable();//关闭 csrf,后面讨论
}
用 postman 访问
登陆失败:
=done&style=none&taskId=u3ca4cd9b-a43a-471b-ad8f-d6b52921c32&title=&width=894)
2. 未登陆返回json
目前用户没有登陆,访问:localhost:8080/sec,会跳转到登陆页面,也让它返回json
// configure 方法中新增
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
Map<String,String> map = new HashMap<>();
map.put("errMsg", "尚未登录,请先登录");
out.write(JSONObject.toJSONString(map));
out.flush();
out.close();
}
});
重启后,访问:localhost:8080/sec
3. 退出登录
http.logout().logoutUrl("/user/logout")
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write("退出成功");
out.flush();
out.close();
}
});
重启后,访问:localhost:8080/user/logout
4. json格式登陆
目前为止我们都是用form表单的方式提交用户名、密码,这种方式:
- 默认是从 **UsernamePasswordAuthenticationFilter **的 attemptAuthentication 方法中获取表单中的参数
我们新建一个类,继承 UsernamePasswordAuthenticationFilter,复写 attemptAuthentication方法,从json中获取参数
/**
* 自定义 UsernamePasswordAuthenticationFilter,从请求中获取用户名密码
*/
public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//如果请求方式不是 post,直接异常
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("请求方式有误:" + request.getMethod());
}
//如果请求的参数格式不是json,直接异常
if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
}
String username=null;
String password = null;
try {
//从 json 数据中 获取用户名、密码
Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
username = map.get("username");//但是参数名可能不是这个,最好是用 getUsernameParameter() 方法获取参数名
password = map.get("password");
} catch (IOException e) {
throw new AuthenticationServiceException("参数不对:" + request.getMethod());
}
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
// 封装用户名、密码,下面的 authenticate 方法会从中拿到 用户名, 调用我们的 LoginUserService 获取用户,然后比较密码
UsernamePasswordAuthenticationToken authRequest
= new UsernamePasswordAuthenticationToken(username,password);
//设置ip、sessionId信息
setDetails(request,authRequest);
// authenticate 方法中封装了具体的密码认证逻辑
return this.getAuthenticationManager().authenticate(authRequest);
}
}
修改 SecurityConfig,声明我们的 **MyUsernamePasswordAuthenticationFilter **为 bean 对象
同时使用下面新的 configure 方法,老的可以暂时注释掉
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 声明一个 PasswordEncoder ,这样数据库存储的密码就不需要 "{加密方式}",这样的前缀
* @return
*/
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter() throws Exception {
MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());//认证使用
//设置登陆成功返回值是json
filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(JSONObject.toJSONString(authentication));
out.flush();
out.close();
}
});
//设置登陆失败返回值是json
filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
Map<String,String> map = new HashMap<>();
map.put("errMsg", exception.getMessage());
out.write(JSONObject.toJSONString(map));
out.flush();
out.close();
}
});
//设置登录接口
filter.setFilterProcessesUrl("/user/login");
return filter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and().csrf().disable();
//把自定义认证过滤器加到拦截器链中
http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
使用 postman 测试:
5. 获取当前登录用户
只有一行代码: SecurityContextHolder.getContext().getAuthentication()
修改 SecController
@GetMapping("/sec")
public String sec(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser currentUser = (LoginUser) authentication.getPrincipal();
return JSONObject.toJSONString(currentUser);
}
登录成功后,访问:localhost:8080/sec
更新当前用户信息:SecurityContextHolder.getContext().setAuthentication(authResult)
// 可以找个地方写个这样的静态方法,如果要更新登录的用户信息,就调用这个方法
public static void setLoginUser(UserDetails userDetails) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()));
}
6. 验证码
添加验证码生成的工具包
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
修改 SecController 增加:
@GetMapping("/code")
public void getVerifyCode(HttpServletResponse resp, HttpSession session) throws IOException {
//验证码配置
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha kaptcha = new DefaultKaptcha();
kaptcha.setConfig(config);
//生成验证码
String text = kaptcha.createText();
//放到session中
session.setAttribute("verify_code", text);
//返回给前端
resp.setContentType("image/jpeg");
BufferedImage image = kaptcha.createImage(text);
try(ServletOutputStream out = resp.getOutputStream()) {
ImageIO.write(image, "jpg", out);
}
}
修改 SecurityConfig
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/code").permitAll()// 验证码请求不用登录
.anyRequest().authenticated()
.and().csrf().disable();
http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
修改 MyUsernamePasswordAuthenticationFilter
.....
String username=null;
String password = null;
String code = null;
try {
Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
code = map.get("code");
username = map.get("username");
password = map.get("password");
} catch (IOException e) {
throw new AuthenticationServiceException("参数不对:" + request.getMethod());
}
// 校验验证码
String verify_code = (String) request.getSession().getAttribute("verify_code");
if (code == null || verify_code == null || !code.equals(verify_code)) {
throw new AuthenticationServiceException("验证码错误");
}
.....
获取验证码:
登录时候,提交验证码:
7. 认证流程
1. UsernamePasswordAuthenticationFilter
第一步:在 UsernamePasswordAuthenticationFilter 中,通过attemptAuthentication 方法
从 request 中获取前端提交的用户名和密码,
- 封装为 :UsernamePasswordAuthenticationToken(它实现了 Authentication 接口)
然后将Authentication 提交给 认证管理器(AuthenticationManager)进行认证
- 就是这句代码:this.getAuthenticationManager().authenticate(authRequest);
2. AuthenticationManager
进入 authenticate 方法
这个方法中,只关心哪里使用了我们传进去的参数:authentication
这个参数,封装了前端提交的 用户名和密码
继续debug
进入上图中的 authenticate 方法
用户登录的方式有很多种,比如:用户名/密码、手机验证码、扫码登录等
每一种都有特定的 Provider 负责处理,DaoAuthenticationProvider 就是负责验证用户名、密码这种方式的登录
同时不同的 Proivder 支持不同的参数(通过supports方法判断)
进入 DaoAuthenticationProvider 的 supports 方法
-
注意:DaoAuthenticationProvider 类中,没有supports 方法,而是在它的父类中
-
也就是:AbstractUserDetailsAuthenticationProvider :
-
上图中可以看出来,DaoAuthenticationProvider 只支持 UsernamePasswordAuthenticationToken 类型
-
所以:DaoAuthenticationProvider 才真正封装了 用户名/密码的 认证逻辑
- 具体是在它的** **authenticate 方法中,(注意:这个方法还是定义在它的父类中)
3. **DaoAuthenticationProvider **
直接再它的 authenticate 方法中打断点
8. 手机验证码登录
账号密码是最常见的登录方式,但是现在的登录多种多样:手机验证码、二维码、第三方授权等等
下面模仿账号密码登录,新增一下手机验证码登录
1. 获取手机验证码
修改 SecController 增加:
@GetMapping("/phone/code")
public String phoneCode(HttpSession session) throws IOException {
//验证码配置
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha kaptcha = new DefaultKaptcha();
kaptcha.setConfig(config);
//生成验证码
String code = kaptcha.createText();
session.setAttribute("phoneNum", code);
return code;
}
2. PhoneNumAuthenticationToken
用户名密码登录用的是 UsernamePasswordAuthenticationToken,继承 AbstractAuthenticationToken
我们新建 PhoneNumAuthenticationToken 继承 AbstractAuthenticationToken
/**
* 模仿 UsernamePasswordAuthenticationToken
* 用来封装前端传过来的手机号、验证码
*/
public class PhoneNumAuthenticationToken extends AbstractAuthenticationToken {
private final Object phone;//手机号
private Object num;//验证码
public PhoneNumAuthenticationToken(Object phone, Object num) {
super(null);
this.phone = phone;
this.num = num;
setAuthenticated(false);
}
public PhoneNumAuthenticationToken(Object phone, Object num, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.phone = phone;
this.num = num;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return num;
}
@Override
public Object getPrincipal() {
return phone;
}
}
3. PhoneNumAuthenticationFilter
之前的 UsernamePasswordAuthenticationFilter 拦截的是 /user/login 请求,从json中获取用户名、密码
参考 UsernamePasswordAuthenticationFilter 写一个过滤器,拦截短信登录接口/phone/login
新建 PhoneNumAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter
/**
* 模仿 UsernamePasswordAuthenticationFilter 获取前端传递的 手机号、验证码
*/
public class PhoneNumAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// 表示这个 Filter 拦截 /phone/login 接口
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher("/phone/login", "POST");
// 参数名
private String phoneParameter = "phone";
private String numParameter = "num";
public PhoneNumAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
/**
* 用来获取前端传递的手机号和验证码,然后调用 authenticate 方法进行认证
* @param request
* @param response
* @return
* @throws AuthenticationException
* @throws IOException
* @throws ServletException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (!"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException("请求方式有误: " + request.getMethod());
}
//如果请求的参数格式不是json,直接异常
if (!request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)){
throw new AuthenticationServiceException("参数不是json:" + request.getMethod());
}
// 用户以json的形式传参的情况下
String phone = null;
String num = null;
try {
Map<String,String> map = JSONObject.parseObject(request.getInputStream(),Map.class);
phone = map.get(phoneParameter);
num = map.get(numParameter);
} catch (IOException e) {
throw new AuthenticationServiceException("参数不对:" + request.getMethod());
}
if (phone == null) {
phone = "";
}
if (num == null) {
num = "";
}
phone = phone.trim();
// 封装手机号、验证码,后面框架会从中拿到 手机号, 调用我们的 LoginPhoneService 获取用户
PhoneNumAuthenticationToken authRequest
= new PhoneNumAuthenticationToken(phone, num);
//设置ip、sessionId信息
setDetails(request,authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
protected void setDetails(HttpServletRequest request, PhoneNumAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}
4. LoginPhoneService
新建 LoginPhoneService
@Component
public class LoginPhoneService implements UserDetailsService {
@Autowired
private UserService userService;
/**
* 根据手机号查询用户对象
* @param phone 前端传的手机号
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
// 从数据库查询用户
User user = userService.getByPhone(phone);
if(user == null){
return null;
}
// 把用户信息封装到一个 userdetails 对象中,UserDetails是一个接口,LoginUser实现了这个接口
LoginUser loginUser = new LoginUser();
loginUser.setUser(user);
return loginUser;
}
}
注意,这里需要修改数据库的user表,增加 phone 字段
5. PhoneAuthenticationProvider
之前说过:this.getAuthenticationManager().authenticate(authRequest); 这句代码,其中的 authenticate 方法封装了具体的用户名、密码热证逻辑,其实里面是调用了 DaoAuthenticationProvider 的 authenticate 方法
用户登录的方式有很多种,每一种都有特定的 Provider 负责处理,
DaoAuthenticationProvider 就是负责验证用户名、密码这种方式的登录
我们的手机号、验证码登录,需要自己创建一个 Provider
新建 PhoneAuthenticationProvider 实现 AuthenticationProvider 接口,主要实现 authenticate 方法,写我们自己的认证逻辑
/**
* 主要实现 authenticate 方法,写我们自己的认证逻辑
*/
@Component
public class PhoneAuthenticationProvider implements AuthenticationProvider {
@Autowired
private LoginPhoneService loginPhoneService;
/**
* 手机号、验证码的认证逻辑
* @param authentication 其实就是我们封装的 PhoneNumAuthenticationToken
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
PhoneNumAuthenticationToken token = (PhoneNumAuthenticationToken) authentication;
String phone = (String) token.getPrincipal();// 获取手机号
String num = (String) token.getCredentials(); // 获取输入的验证码
// 1. 从 session 中获取验证码
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String phoneNum = (String) req.getSession().getAttribute("phoneNum");
if (!StringUtils.hasText(phoneNum)) {
throw new BadCredentialsException("验证码已经过期,请重新发送验证码");
}
if (!phoneNum.equals(num)) {
throw new BadCredentialsException("验证码不正确");
}
// 2. 根据手机号查询用户信息
LoginUser loginUser = (LoginUser) loginPhoneService.loadUserByUsername(phone);
if (loginUser == null) {
throw new BadCredentialsException("用户不存在,请注册");
}
// 3. 把用户封装到 PhoneNumAuthenticationToken 中,
// 后面就可以使用 SecurityContextHolder.getContext().getAuthentication(); 获取当前登陆用户信息
PhoneNumAuthenticationToken authenticationResult = new PhoneNumAuthenticationToken(loginUser, num, loginUser.getAuthorities());
authenticationResult.setDetails(token.getDetails());
return authenticationResult;
}
/**
* 判断是上面 authenticate 方法的 authentication 参数,是哪种类型
* Authentication 是个接口,实现类有很多,目前我们最熟悉的就是 PhoneNumAuthenticationToken、UsernamePasswordAuthenticationToken
* 很明显,我们只支持 PhoneNumAuthenticationToken,因为它封装的是手机号、验证码
* @param authentication
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
// 如果参数是 PhoneNumAuthenticationToken 类型,返回true
return (PhoneNumAuthenticationToken.class.isAssignableFrom(authentication));
}
}
6. 配置 SecurityConfig
下面最重要的,把上面的东西配置到 SecurityConfig 中,让其生效
@Bean
public PhoneNumAuthenticationFilter phoneNumAuthenticationFilter() throws Exception {
PhoneNumAuthenticationFilter filter = new PhoneNumAuthenticationFilter();
filter.setAuthenticationManager(authenticationManagerBean());//认证使用
//设置登陆成功返回值是json
filter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(JSONObject.toJSONString(authentication));
out.flush();
out.close();
}
});
//设置登陆失败返回值是json
filter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
Map<String,String> map = new HashMap<>();
map.put("errMsg", "手机登陆失败:"+ exception.getMessage());
out.write(JSONObject.toJSONString(map));
out.flush();
out.close();
}
});
filter.setFilterProcessesUrl("/phone/login");//其实这里不用设置,在 PhoneNumAuthenticationFilter 我们已经定义了一个静态变量
return filter;
}
@Autowired
private LoginUserService loginUserService;
/**
* DaoAuthenticationProvider 是默认做账户密码认证的,现在有两种登录方式,手机号和账户密码
* 如果不在这里声明,账户密码登录不能用
* @return
*/
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
//对默认的UserDetailsService进行覆盖
authenticationProvider.setUserDetailsService(loginUserService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
return authenticationProvider;
}
@Autowired
private PhoneAuthenticationProvider phoneAuthenticationProvider;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// /phone/code 请求不用登陆
.antMatchers("/code","/phone/code").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();
http.addFilterAt(myUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.authenticationProvider(daoAuthenticationProvider());//把账户密码验证加进去
//把 手机号认证过滤器 加到拦截器链中
http.addFilterAfter(phoneNumAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.authenticationProvider(phoneAuthenticationProvider);//把验证逻辑加进去
}
7. 测试
不登录访问 localhost:8080/sec
获取手机验证码
输入错误的验证码
输入正确的,登录成功
8. 优化
其实上面的代码可以优化,根据上面的代码逻辑,我们是先根据手机号拿到用户后,再比较验证码是否正确
根据我们之前账户密码登录的经验,比较验证码的代码完全可以放到 PhoneNumAuthenticationFilter 中
但是为了模仿账号密码登录的这个过程,我并没有那样做
10. 认证流程
再次,梳理认证流程
- 用户提交用户名、密码被 UsernamePasswordAuthenticationFilter 过滤器拿到,
- 封装为 :UsernamePasswordAuthenticationToken(它实现了 Authentication 接口)
- 然后将Authentication 提交给 认证管理器(AuthenticationManager)进行认证
- 就是这句代码:this.getAuthenticationManager().authenticate(authRequest);
- authenticate 方法中调用 AuthenticationProvider 对象的 authenticate 方法进行认证
- 手机登录调用的是我们自己的:PhoneAuthenticationProvider
- 账号密码登录调用的是:DaoAuthenticationProvider
- 认证过程:
- 通过 UserDetailsService 查询用户对象
- 手机登录调用的是我们自己的:LoginPhoneService
- 账号密码登录调用的是:LoginUserService
- 比较密码是否正确(手机登录比较的是验证码)
- 成功后重新创建Authentication对象,把当前用户放进去
- 通过 UserDetailsService 查询用户对象
- 认证结束后,把 Authentication 对象,放到 SecurityContext 中
- 执行的是:SecurityContextHolder.getContext().setAuthentication();
- 之后就可以通过SecurityContextHolder.getContext().getAuthentication(); 获取当前用户