Spring Security前后端分离认证
前言:Spring Security是spring提供的一个安全框架,提供了登录认证、密码保护、自动登录等。这是我目前学习到的功能,当然它的强大之处远不止这些了。Spring Security处理登录功能时使用的
form表单,底层获取参数使用的request.getParamter的形式获取的。但是前后端分离模式使用的是异步请求,所以在前后端分离模式下会出现很多问题。以下记录了我出现过的问题。
这里后端使用的是springboot+spring security,使用插件fastjson以及lombok
前端使用Vue+axios+ElementUi(主要为了操作简单)
使用Spring Security主要操作
-
编写Security配置类,继承WebSecurityConfigurerAdapter类,重写两个重要的方法configure(HttpSecurity http)和configure(AuthenticationManagerBuilder auth);参数为http的方法主要配置拦截请求后的操作,比如登录页面,登陆了成功操作,失败操作,或者跨域请求登录问题。参数为auth主要是配置登录认证时使用自定义认证方法。这里需要注意一下几点:
-
必须配置PasswordEncoder @Bean对象, Security的底层是强制对密码进行编码的,如果没有配置,会报错PasswordEncoder为null。
-
Security需要开启跨域访问,代码在参数为http的方法编写
-
Security内部有自带的login页面,当未登录时会默认跳转改页面,如果是前后端分离模式,则会出现302的错误。需要将跳转重新设置,前后端分离情况下不需要返回页面,只需要返回未登录信息,所以应该写一个mapping方法,返回未登录信息,如下:
@RestController
public class LoginController {
@GetMapping("/unLogin")
public String unLogin(){
return "未登录";
}
}
并在配置方法中配置登录页跳转即可: http.loginPage("/unLogin")
- 默认security处理登录失败,登录成功,退出登录跳转之类的使用的是页面表示,对于前后端模式下只需要返回状态即可,这里可以使用XXXHandler进行处理,使用response返回信息即可。
- 其他需要注意的细节在代码中标注。
具体代码如下:
package com.example.securitydemo.config;
import com.alibaba.fastjson.JSON;
import com.example.securitydemo.service.MyUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//自定义Sevice实现UserDetails
@Autowired
private MyUserDetailsService myUserDetailsService;
//前提配置文件中中文件置配置数据源
@Autowired
private DataSource dataSource;
/**
* 实现自动登录功能,配置数据源
*/
@Bean
public PersistentTokenRepository persistentTokenRepository(){
//使用JDBC
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
//创建数据库,只能执行一次,第二次之后需要注掉,否则报错数据库已存在
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
/**
*
* 如果需要实现自己的编码格式,可以实现PasswordEncoder接口,重写encode()方法实现编码
* 设置编码格式,必须设置,否则报错
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//注入自己的userDetailsService
auth.userDetailsService(myUserDetailsService)
//设置密码编码格式
.passwordEncoder(passwordEncoder());
}
/**
* 拦截请求后的权限设置
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test1").authenticated()//设置必须持有凭证才可以访问路径
.antMatchers("/test2").permitAll()//设置无需凭证访问路径
.and().formLogin() //使用自带的登录
//登录页表示未登录的时候跳转的路径
.loginPage("/unLogin")
.loginProcessingUrl("/login")//设置登录请求路径
//登录失败,返回json
.failureHandler((request, response, ex) -> {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap();
map.put("code", 401);
if (ex instanceof UsernameNotFoundException || ex instanceof BadCredentialsException) {
map.put("message", "用户名或密码错误");
} else if (ex instanceof DisabledException) {
map.put("message", "账户被禁用");
} else {
map.put("message", "登录失败!");
}
out.println(JSON.toJSONString(map));
out.flush();
out.close();
})
//登录成功,返回json
.successHandler((request, response, authentication) -> {
Map<String, Object> map = new HashMap();
map.put("code", 200);
map.put("message", "登录成功");
map.put("data", authentication);
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.println(JSON.toJSONString(map));
out.flush();
out.close();
})
.and()
.exceptionHandling()
//没有权限,返回json
.accessDeniedHandler((request, response, ex) -> {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap();
map.put("code", 403);
map.put("message", "权限不足");
out.println(JSON.toJSONString(map));
out.flush();
out.close();
})
//自动登录(记住我功能)
.and()
.rememberMe().tokenRepository(persistentTokenRepository())
.rememberMeParameter("rememberMe")//参数名称
.userDetailsService(myUserDetailsService)//使用自定义service操作数据库
.tokenValiditySeconds(60)//已秒为单位
.and()
.logout()
//退出登录url
.logoutUrl("/logout")
//退出成功,返回json
.logoutSuccessHandler((request, response, authentication) -> {
Map<String, Object> map = new HashMap();
map.put("code", 200);
map.put("message", "退出成功");
map.put("data", authentication);
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.println(JSON.toJSONString(map));
out.flush();
out.close();
})
.permitAll()
//开启跨域访问
.and().cors()
//开启模拟请求,比如API POST测试工具的测试,不开启时,API POST为报403错误
.and().csrf().disable()
//未登录返回状态设置
.exceptionHandling().authenticationEntryPoint((request, response, e) ->
//没有访问权限
response.setStatus(HttpStatus.UNAUTHORIZED.value()));;
}
}
- 上面有提过自定义认证方法,security底层认证过程是通过一个UserDetailsService的实现类,实现了loadUserByUsername(String username)方法,进行验证的,所以这里创建了MyUserDetailsService进行重写这个方法实现自定义登录验证方法。在这里可以在使用JDBC的方式访问数据库,查询username账户,账户不存在表示数据库没有数据,账户存在并对密码进行加密,然后返回一个UserDetails对象,然后可以security底层去判断是否登录成功。需要构建的代码如下:
package com.example.securitydemo.service;
import com.example.securitydemo.entity.User;
import com.example.securitydemo.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
/**
* 重写loadUserByUsername方法,实现登录验证
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userMapper.selectByUserName(username);
//如果用户名不存在
if (user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
user.setPassword(new BCryptPasswordEncoder().encode(user.getPassword()));
return user;
}
}
package com.example.securitydemo.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
private String username;
private String password;
/**
* 访问权限的角色,这里可以定义用户的访问权限
* @return
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//访问角色,这里暂时没有用。据网上说这里设置为null会显示没有访问权限登录不进去。
// 但是我没有出现过这个问题,这里先简单的设置一下,不过没有什么用处
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
- 接下来就是前端页面设置了,前端界面很简单,但是需要注意的是,登录账户名必须是username,密码必须是password,记住我的复选框名称必须是rememberMe,这里security的底层这样定义名字的, 可以在security的配置文件中修改名称。在我这里的配置中必须使用这个名字,否则报错。页面代码如下:
<!-- 这里最主要的是el-input的代码,其他的是是elementUi的修饰。这里的v-model绑定的是输入框的名称 -->
<div style="width: 300px;height: 100px;">
<el-form ref="form" :model="form" label-width="80px">
<el-form-item label="账号">
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password"></el-input>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.rememberMe">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">登录</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
</div>
- 提交的代码时,axios使用的是JSON对象的格式,并不是form表单格式,网上很多都是
重写security的UsernamePasswordAuthenticationFilter这个过滤器,使其支持对象接收,但是在处理机复选框的时候会出现问题。这里解决的办法是:**既然security处理的是表单,那么前端传递的时候就传递成表单格式就可以了。这里使用Vue自带的qs进行表单格式处理。这样在不修改security的逻辑下,解决了参数问题,避免了重写自定义功能后出现其他异常的连锁反应。**前端完整代码如下:
<template>
<div id="app">
<div style="width: 300px;height: 100px;">
<el-form ref="form" :model="form" label-width="80px">
<el-form-item label="账号">
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password"></el-input>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.rememberMe">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">登录</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script>
export default {
data() {
return {
form: {}
}
},
methods: {
onSubmit() {
let qs = require('qs');
//使用qs将对象转换为form表单
this.$axios.post("/login",qs.stringify(this.form))
.then((response) => {
console.log(response)
}).catch((err) => {
console.log(err)
})
}
}
}
</script>
<style>
#app {}
</style>
以上就是我遇到的问题。前端的额外处理这里就不介绍了,可以在axios的拦截器功能中拦截请求,如果登录失败的*跳转登录页即可。记住我的功能可以访问后端获取用户信息,这样当用户不能访问信息时,表示账户登录信息失效。非常完美的框架