1,快速入门
1.1 搭建springboot项目
1.2 导 SpringSecurity依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
导入依赖后,再启动项目,security会自动拦截访问的接口。用户名:user;密码会在控制台中打印出来。
2,认证
2.1 登录校验流程
2.1 原理初探
2.2.1 SpringSecurity 完整流程
SpringSecurity的原理其实是一个过滤器链,内部包含各种功能过滤器。下图为引入security依赖后自带的过滤器。
- UsernamePasswordAuthenticationFilter:对用户名密码的过滤。
- ExceptionTranslationFilter:对异常的捕获处理。
- FilterSecurityInterceptor:权限校验过滤器。
2.2.2 认证流程详解
登录接口
其他接口
2.3 解决问题
2.3.1 思路分析
登录:
-
自定义登录接口
- 调用ProviderManager的方法进行认证,如果认证通过生成jwt,把用户信息存入redis中
-
自定义UserDetailsService
- 在这个实现查询数据库
校验:
-
定义jwt认证过滤器
- 获取token
- 解析token,获取其中userid
- 从redis中获取用户信息
- 存入securityContextHolder中
2.3.2 准备工作
配置redis
添加依赖
<!--redis依赖配置-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
添加配置类
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
//key采用String的序列化方式
redisTemplate.setKeySerializer(new StringRedisSerializer());
// hash的key也采用String的序列化方式
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
RedisSerializer<Object> serializer = redisSerializer();
// value序列化方式采用jackson
redisTemplate.setValueSerializer(serializer);
// hash的value序列化方式采用jackson
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
@Bean
public RedisSerializer<Object> redisSerializer(){
//创建json序列化器
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//必须设置,否则无法将json转化为对象,会转化为map类型
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(objectMapper);
return serializer;
}
}
改yml
spring:
redis:
host: 192.168.0.238
port: 6379
封装返回结果
public class CommonResult<T>{
private long code;
private String message;
private T data;
protected CommonResult() {
}
public CommonResult(long code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
/**
* 成功返回结果
* @param data 获取的数据
* @return
*/
public static <T> CommonResult<T> success(T data){
return new CommonResult<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
/**
* 成功返回结果
* @param message 提示信息
* @param data 获取的数据
* @return
*/
public static <T> CommonResult<T> success(String message,T data){
return new CommonResult<T>(ResultCode.SUCCESS.getCode(), message, data);
}
/**
* 失败返回结果
*/
public static <T> CommonResult<T> failed(){
return new CommonResult<T>(ResultCode.FAILED.getCode(), ResultCode.FAILED.getMessage(), null);
}
/**
* 失败返回结果
* @param message 提示信息
* @return
*/
public static <T> CommonResult<T> failed(String message){
return new CommonResult<>(ResultCode.FAILED.getCode(), message,null);
}
/**
* 失败返回结果
* @param errorCode 错误码
* @return
*/
public static <T> CommonResult<T> failed(IErrorCode errorCode){
return new CommonResult<>(errorCode.getCode(),errorCode.getMessage(),null);
}
/**
* 失败返回结果
* @param errorCode 错误码
* @param message 错误信息
*/
public static <T> CommonResult<T> failed(IErrorCode errorCode, String message) {
return new CommonResult<T>(errorCode.getCode(), message, null);
}
/**
* 参数验证失败返回结果
*/
public static <T> CommonResult<T> validateFailed() {
return failed(ResultCode.VALIDATE_FAILED);
}
/**
* 参数验证失败返回结果
* @param message 提示信息
*/
public static <T> CommonResult<T> validateFailed(String message) {
return new CommonResult<T>(ResultCode.VALIDATE_FAILED.getCode(), message, null);
}
/**
* 未登录返回结果
*/
public static <T> CommonResult<T> unauthorized(T data) {
return new CommonResult<T>(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data);
}
/**
* 未授权返回结果
*/
public static <T> CommonResult<T> forbidden(T data) {
return new CommonResult<T>(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data);
}
public long getCode() {
return code;
}
public void setCode(long code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
public enum ResultCode implements IErrorCode {
SUCCESS(200,"操作成功"),
FAILED(500,"操作失败"),
VALIDATE_FAILED(404,"参数检验失败"),
UNAUTHORIZED(401,"没登录或token过期"),
FORBIDDEN(403,"没有相关权限");
private long code;
private String message;
private ResultCode(long code,String message){
this.code = code;
this.message = message;
}
@Override
public long getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}
public interface IErrorCode {
long getCode();
String getMessage();
}
引入jwt
引依赖
<!--JWT(Json Web Token)登录支持-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
工具类
2.3.3 实现
2.3.3.1 数据库校验用户
数据库配置
导入数据库依赖
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- mybatis-plus-generator -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.2</version>
</dependency>
<!--Velocity模板引擎 mybatis-plus -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.3</version>
</dependency>
<!--Mysql数据库驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>
<!--集成druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.9</version>
</dependency>
mybatis-plus配置
@Configuration
public class MybatisPlusConfig {
/**
* 分页插件
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
代码生成器配置
public class MybatisPlusGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotEmpty(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
//获取程序当前路径
String projectPath = System.getProperty("user.dir");
// String projectPath = "D:\java测试目录";
// 数据源配置
String dbUrl = "jdbc:mysql://localhost:3306/mall_tiny?characterEncoding=UTF-8&useUnicode=true&useSSL=false";
DataSourceConfig.Builder dataSourceConfig = new DataSourceConfig.Builder(dbUrl,"root","12345")
.dbQuery(new MySqlQuery())
.typeConvert(new MySqlTypeConvert())
.keyWordsHandler(new MySqlKeyWordsHandler());
// 代码生成器
FastAutoGenerator mpg = FastAutoGenerator.create(dataSourceConfig);
mpg.globalConfig(globalBuilder -> globalBuilder.fileOverride().disableOpenDir()
.outputDir(projectPath + "/src/main/java")
.author("")
.commentDate("yyyy-MM-dd HH:mm:ss")
.dateType(DateType.TIME_PACK)
.enableSwagger());
mpg.packageConfig(packageBuilder -> packageBuilder
.parent("com.xybian.mall.tiny")
.service("services")
.serviceImpl("services.impl")
.xml("mybatis")
);
mpg.strategyConfig(strategyconfigBuilder -> strategyconfigBuilder
.enableCapitalMode()
.enableSkipView ()
.disableSqlFilter()
.addInclude(scanner("表名"))
);
//entity 生成策略
mpg.strategyConfig(strategyconfigBuilder -> strategyconfigBuilder.entityBuilder()
// .enableTableFieldAnnotation ()
.naming (NamingStrategy.underline_to_camel)
.columnNaming (NamingStrategy.underline_to_camel)
.idType(IdType.AUTO)
// .enableLombok ()
/*.logicDeleteColumnName ( "deleted ").logicDeletePropertyName ( "deleted ")
.addTableFills(new Column( "create_time" , FieldFill.INSERT))
.addTableFills(new Property( "updateTime " ,FieldFill.INSERT_UPDATE))*/
.versionColumnName ("version")
.disableSerialVersionUID()
);
//controller 生成策略
mpg.strategyConfig(
strategyconfigBuilder ->strategyconfigBuilder
.controllerBuilder()
.enableRestStyle()
.enableHyphenStyle()
);
//service 生成策略
mpg.strategyConfig(
strategyconfigBuilder -> strategyconfigBuilder
.serviceBuilder()
.formatServiceFileName ("%sService")
.formatServiceImplFileName ("%sServiceImpl"));
//mapper 生成策略
mpg.strategyConfig (
strategyconfigBuilder ->strategyconfigBuilder.mapperBuilder()
.formatMapperFileName ("%sMapper")
.formatXmlFileName ( "%sMapper" )
.enableBaseResultMap());
mpg.execute();
}
}
改yml
spring:
redis:
host: 192.168.0.238
port: 6379
datasource:
druid:
url: jdbc:mysql://localhost:3306/sys_secutiry?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 12345
driver-class-name: com.mysql.cj.jdbc.Driver
#mybatis plus 设置
mybatis-plus:
mapper-locations: classpath:/mybatis/*Mapper.xml
global-config:
db-config:
id-type: auto
configuration:
auto-mapping-behavior: partial
map-underscore-to-camel-case: true
添加包扫描
添加controller,mapper,service 并测试
校验配置
有上面讲解的认证流程,我们可以知道,主要是修改 UserDetailsService 这个接口的实现类,来实现从数据库去对 用户名和密码 的校验。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private SysUserService sysUserService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//从数据库查询校验
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUserName,username);
SysUser sysUser = sysUserService.getOne(wrapper);
if (sysUser == null){
throw new UsernameNotFoundException("用户不存在");
}
//授权
//返回
return new UserDetailsImpl(sysUser);
}
}
因为上面的返回,需要为UserDetails接口,所以在定义一个UserDetailsImpl类,实现接口,去返回数据。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private SysUser sysUser;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return sysUser.getPassword();
}
@Override
public String getUsername() {
return sysUser.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
以上修改完成后,就可以做到从数据库去认证用户。
SpringSecurity密码校验机制:数据库存储的密码格式为 {加密方式}password 。校验时,会先从数据库读取密码,获取密码前的 { } 中的加密方式,然后用这个加密方式对登录时的密码加密,再和数据库密码对比。
注:{noop} 表示不加密,按明文显示。
2.3.3.2 密码加密存储
修改SpringSecurity默认的加密方式,改成 BCryptPasswordEncoder
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//创建 BCryptPasswordEncoder 注入容器
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
2.3.3.3 登录接口
先自定义一个登录接口
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/user/login")
public CommonResult login(@RequestBody SysUser user) throws AuthenticationException {
CommonResult result = loginService.login(user);
return result;
}
}
登录方法的实现
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private RedisTemplate redisTemplate;
@Override
public CommonResult login(SysUser user) throws AuthenticationException {
//封装 Authentication
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
//认证用户
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (authenticate == null){
throw new AuthenticationException("认证失败");
}
UserDetailsImpl userDetails = (UserDetailsImpl)authenticate.getPrincipal();
SysUser sysUser = userDetails.getSysUser();
//认证通过,生成jwt
Map<String, Object> map = new HashMap<>();
map.put("userId",sysUser.getId());
String token = jwtTokenUtil.generateToken(map);
//用户信息存入 redis 中
redisTemplate.opsForValue().set("user-"+sysUser.getId(),sysUser);
//将token信息返回
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("token",token);
return CommonResult.success(dataMap);
}
}
securityConfig配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//创建 BCryptPasswordEncoder 注入容器
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
//为登录接口 提供下一步 调用的方法
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
//放行登录方法
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭 csrf
.csrf().disable()
//不通过 session 获取 SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对登录接口 允许匿名访问 /user/login
.antMatchers("/user/login").anonymous()
//除上面外的所有请求全部需要 鉴权认证
.anyRequest().authenticated();
}
}
接口测试
2.3.3.4 认证过滤器
添加一个对token验证的过滤器,同时设置该过滤器在 UsernamePasswordAuthenticationFilter 之前运行,这个保证先验证token,在验证用户登录。
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil tokenUtil;
@Autowired
private RedisTemplate redisTemplate;
@SneakyThrows
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
// 获取 token
String token = httpServletRequest.getHeader("token");
if (!StringUtils.hasText(token)){
filterChain.doFilter(httpServletRequest,httpServletResponse);
return;
}
// 解析token,获取 userId
Claims claims = tokenUtil.getClaimsFromToken(token);
String userId = claims.get("userId").toString();
//从redis中获取User
Object o = redisTemplate.opsForValue().get("user-" + userId);
if (ObjectUtils.isEmpty(o)){
throw new AuthenticationException("没有用户信息");
}
SysUser user = (SysUser)o;
//将User 封装到 securityContextHolder。 封装到securityContextHolder以后,其他过滤器就不会在拦截
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
securityConfig中的配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭 csrf
.csrf().disable()
//不通过 session 获取 SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对登录接口 允许匿名访问 /user/login
.antMatchers("/user/login").anonymous()
//除上面外的所有请求全部需要 鉴权认证
.anyRequest().authenticated();
//定义filter的先后顺序,保证 jwtFilter比用户验证的过滤器先执行
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
}
2.3.3.5 退出登录
因为每一次请求都是一个新的 securityContextHolder,所以将 redis 中用户信息删除后,在用token访问接口时,在获取 redis 用户信息时,无法通过,即注销成功。
@GetMapping("/user/logout")
public CommonResult logout(){
return loginService.logout();
}
@Override
public CommonResult logout() {
//获取 securityContextHolder
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
SysUser user = (SysUser)authentication.getPrincipal();
//删除redis中的用户信息
redisTemplate.delete("user-"+user.getId());
return CommonResult.success("注销成功",null);
}
3,授权
1,开启权限注解
2,在需要设置权限的接口上加上权限限制
3,在做登录用户校验的时候,从数据库查询设置该用户的权限,本测试先写死。
4,对token过滤器修改
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil tokenUtil;
@Autowired
private RedisTemplate redisTemplate;
@SneakyThrows
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
// 获取 token
String token = httpServletRequest.getHeader("token");
if (!StringUtils.hasText(token)){
filterChain.doFilter(httpServletRequest,httpServletResponse);
return;
}
// 解析token,获取 userId
Claims claims = tokenUtil.getClaimsFromToken(token);
String userId = claims.get("userId").toString();
//从redis中获取User
Object o = redisTemplate.opsForValue().get("user-" + userId);
if (ObjectUtils.isEmpty(o)){
throw new AuthenticationException("没有用户信息");
}
SysUser user = (SysUser)o;
//用户权限信息
List<SimpleGrantedAuthority> authorityList = user.getPermissions().stream().map(permission -> new SimpleGrantedAuthority(permission)).collect(Collectors.toList());
//将User 封装到 securityContextHolder。 封装到securityContextHolder以后,其他过滤器就不会在拦截
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user,null,authorityList);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
RBAC权限模型
4,自定义失败提示
实现这个两个接口。再将这两个实现类添加到securityConfig的配置中。
AuthenticationEntryPoint 认证异常
AccessDeineHandler 鉴权异常
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>();
map.put("uri",httpServletRequest.getRequestURI());
map.put("msg","鉴权失败");
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
httpServletResponse.setCharacterEncoding("utf-8");
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
String resBody = objectMapper.writeValueAsString(map);
PrintWriter printWriter = httpServletResponse.getWriter();
printWriter.print(resBody);
printWriter.flush();
printWriter.close();
}
}
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
Map<String, Object> map = new HashMap<>();
map.put("uri",httpServletRequest.getRequestURI());
map.put("msg","认证失败");
httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpServletResponse.setCharacterEncoding("utf-8");
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
String resBody = objectMapper.writeValueAsString(map);
PrintWriter printWriter = httpServletResponse.getWriter();
printWriter.print(resBody);
printWriter.flush();
printWriter.close();
}
}
5,跨域
浏览器出于安全的考虑,使用XMLHttpRequest对象发起HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
先在springboot中配置
再在springsecurity中配置
6,补充
6.1 自定义权限校验方法
/**
* 自定义权限校验方法
*/
@Component
public class AuthenticationExpression {
public boolean hasTrue(String permission){
//从 securityContextHolder中获取用户权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl userDetails = (UserDetailsImpl)authentication.getPrincipal();
//权限信息
List<String> permissions = userDetails.getPermissions();
//判断是否有权限
return permissions.contains(permission);
}
}
6.2,基于配置设置设置权限
6.3, CSRF
CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。
后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。
6.4 处理器
认证成功处理器
认证失败处理器
登录成功处理器
7,代码地址
gitee地址:gitee.com/xinyunbian/…
8,参考讲解
- home.cnblogs.com/u/bug9/ 的SpringSecurity讲解。