spring-security实践
本文主要基于实际应用中的实现,相关理论不做分析;前提准备代码编辑器,redis服务,数据库服务。
数据库
基于RBAC设计相关表信息,表格信息简化了,只保留主要字段
/*
Navicat Premium Data Transfer
Source Server : locahost
Source Server Type : MySQL
Source Server Version : 50717
Source Host : localhost:3306
Source Schema : security
Target Server Type : MySQL
Target Server Version : 50717
File Encoding : 65001
Date: 14/04/2023 16:09:58
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for permission
-- ----------------------------
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `permission` (
`id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`action` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`url` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`role_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for role_permission_rel
-- ----------------------------
DROP TABLE IF EXISTS `role_permission_rel`;
CREATE TABLE `role_permission_rel` (
`id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`role_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`permission_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`password` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `user_role_rel`;
CREATE TABLE `user_role_rel` (
`id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`role_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
实体类及相关工具创建
这里我使用的是JPA,通过这种方式创建的表实体及Repository,如果使用其它框架的(如:mybaties)可根据自身建立
其次对于UserDetails重新实现,主要通过User对象获取相关内容信息,注意@JsonIgnore注释以及重写的方法的返回实现;我后面将整个对象作为value值存入redis中,用jackson序列化的出现过一些错误,最后通过@JsonIgnore将字段值信息忽略了才成功
package com.yi.an.security.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.yi.an.security.po.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Data
public class LoginUser implements UserDetails {
private User user;
//存储权限信息
private List<String> permissions;
@JsonIgnore
private List<GrantedAuthority> authorities;
public LoginUser() {}
public LoginUser(User user, List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if(authorities!=null){
return authorities;
}
//把permissions中权限信息转换成GrantedAuthority对象存出
authorities = permissions.stream().
map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
@JsonIgnore
public String getPassword() {
return user.getPassword();
}
@Override
@JsonIgnore
public String getUsername() {
return user.getUserName();
}
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return true;
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isEnabled() {
return true;
}
}
接口封装返回类
package com.yi.an.security.vo;
public class Result<T> {
/**
* 状态码
*/
private Integer code;
/**
* 错误信息
*/
private String msg;
/**
* 结果
*/
private T data;
public Result(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Result(Integer code, T data) {
this.code = code;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public Result(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
Redis配置
package com.yi.an.security.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(om);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
Redis工具
package com.yi.an.security.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Component
public class RedisUtil
{
@Autowired
public RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
*/
public <T> void setCacheObject(final String key, final T value)
{
redisTemplate.opsForValue().set(key, value);
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功;false=设置失败
*/
public boolean expire(final String key, final long timeout, final TimeUnit unit)
{
return redisTemplate.expire(key, timeout, unit);
}
/**
* 获得缓存的基本对象。
*/
public <T> T getCacheObject(final String key)
{
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
/**
* 删除单个对象
*/
public boolean deleteObject(final String key)
{
return redisTemplate.delete(key);
}
}
JWT工具
package com.yi.an.security.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
public class JwtUtil {
//有效期
public static final TOKEN_EXPIRATION = 60 * 60 * 1000L;
//秘钥明文
public static final String TOKEN_KEY = "yian";
public static String getUUID() {
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
*
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, generalKey())
.setExpiration(expDate);
}
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(TOKEN_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析token获取用户信息
* @param token
*/
public static Claims parseJWT(String token) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
public static String createToken(String userName, String role) {
String token = Jwts.builder().setSubject(userName)
.claim(userRoleKey, role)
.setExpiration(new Date(System.currentTimeMillis() + TOKEN_EXPIRATION))
.signWith(SignatureAlgorithm.HS512, generalKey()).compressWith(CompressionCodecs.GZIP).compact();
return token;
}
public static String getUserNameFromToken(String token) {
String userName = Jwts.parser().setSigningKey(generalKey()).parseClaimsJws(token).getBody().getSubject();
return userName;
}
public static String getUserRoleFromToken(String token) {
Claims claims = Jwts.parser().setSigningKey(generalKey()).parseClaimsJws(token).getBody();
return claims.get(TOKEN_KEY).toString();
}
}
security配置类
这里主要配置登录接口,接口请求的前置拦截对token的处理,以及将数据库中权限url加入配置中实现各用户对不同url的权限
package com.yi.an.security.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yi.an.security.dao.PermissionDao;
import com.yi.an.security.filter.JwtTokenFilter;
import com.yi.an.security.handler.*;
import com.yi.an.security.po.Permission;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;
import java.util.function.Consumer;
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PermissionDao permissionDao;
@Autowired
private JwtTokenFilter jwtTokenFilter;
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AccessDeniedHandler accessDeniedHandler;
@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()
// 登录接口 允许匿名访问
.antMatchers("/yian/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
// .anyRequest().authenticated()
.and()
//把token校验过滤器添加到过滤器链中
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
/**
* 连接数据库动态查找权限
*/
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorizeRequests = http.authorizeRequests();
//需要查询到所有的权限
List<Permission> allPermission = permissionDao.findAll();
allPermission.forEach((p->{
//添加规则
authorizeRequests.antMatchers(p.getUrl()).hasAnyAuthority(p.getAction());
}));
authorizeRequests.antMatchers("/**").fullyAuthenticated();
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
//配置授权失败处理器
.accessDeniedHandler(accessDeniedHandler);
http.formLogin()
// 配置认证成功处理器
.successHandler(new SuccessHandler())
// 配置认证失败处理器
.failureHandler(new FailureHandler());
http.logout()
//配置注销成功处理器
.logoutSuccessHandler(new LogoutSuccessHandlerImpl());
}
/**
* 身份认证 这里关联数据库和security
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
请求拦截,校验token
拦截所有的请求,对于有token的请求进行token校验
package com.yi.an.security.filter;
import com.yi.an.security.utils.JwtUtil;
import com.yi.an.security.utils.RedisUtil;
import com.yi.an.security.vo.LoginUser;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Objects;
import java.util.Set;
/**
* 请求拦截,校验token
*/
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisUtil redisUtil;
@Autowired
private com.yi.an.security.dao.PermissionDao permissionDao;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token为空并且是登录是不解析
String token = request.getHeader("token");
if (StringUtils.isEmpty(token) && request.getRequestURI().contains("/yian/login")) {
filterChain.doFilter(request, response);
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "yian" + userid;
Object o = redisUtil.getCacheObject(redisKey);
System.out.println(redisUtil.getCacheObject(redisKey).toString());
// 这里序列化的时LoginUser对象没有ignore可能会出错
LoginUser loginUser = redisUtil.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//获取权限信息存入SecurityContextHolder,LoginUser中也包含了权限信息,loginUser缓存时是否可简化,这样再获取redis数据时就不会出错
Set<String> roleIds = permissionDao.findRoleIdByUserId(userid);
ArrayList<GrantedAuthority> auths = new ArrayList<GrantedAuthority>();
roleIds.forEach(ss->auths.add(new SimpleGrantedAuthority(ss)));
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,auths);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
UserDetailsService实现
package com.yi.an.security.service;
import com.yi.an.security.dao.UserDao;
import com.yi.an.security.po.User;
import com.yi.an.security.utils.JwtUtil;
import com.yi.an.security.utils.RedisUtil;
import com.yi.an.security.vo.LoginUser;
import com.yi.an.security.vo.Result;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class UserServiceImpl implements UserDetailsService {
@Autowired
private UserDao userDao;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisUtil redisUtil;
@Autowired
private com.yi.an.security.dao.PermissionDao permissionDao;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userDao.findByUserName(s);
if (null == user) {
// todo exception
throw new RuntimeException("用户名或密码错误");
}
Set<String> roleIds = permissionDao.findRoleIdByUserId(user.getId());
List<String> auths = new ArrayList<>();
roleIds.forEach(ss->auths.add(ss));
LoginUser mu = new LoginUser(user, auths);
return mu;
}
@SneakyThrows
public Result login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId();
String jwt = JwtUtil.createJWT(userId);
//authenticate存入redis
redisUtil.setCacheObject("yian"+userId,loginUser);
//把token响应给前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return new Result(200,"登陆成功",map);
}
}
测试类
package com.yi.an.security.controller;
import com.yi.an.security.po.User;
import com.yi.an.security.service.UserServiceImpl;
import com.yi.an.security.utils.RedisUtil;
import com.yi.an.security.vo.LoginUser;
import com.yi.an.security.vo.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LoginController {
@Autowired
private UserServiceImpl userService;
@Autowired
private RedisUtil redisUtil;
@PostMapping("/yian/login")
public Result login(@RequestBody User user){
System.out.println(user);
return userService.login(user);
}
@GetMapping("/login/out")
public Result logout() {
// 在拦截中已将用户信息存入security上下文
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
String userid = loginUser.getUser().getId();
redisUtil.deleteObject("yian"+userid);
return new Result(200,"退出成功");
}
@PostMapping ("/add")
public String add() {
System.out.println("add===========");
return "add";
}
@PostMapping("/yian/test")
public String test() {
System.out.println("test===========");
return "test";
}
}
相关后续处理接口
AccessDeniedHandler – 授权异常处理接口
AuthenticationEntryPoint – 认证异常接口
AuthenticationFailureHandler – 认证失败处理接口
LogoutSuccessHandler – 注销退出处理接口
这些接口都可以通过继承重写实现业务需求相关逻辑,在SecurityConfig中进行配置
登录认证流程
通过/yian/login接口登录,将登录的用户名密码封装进UsernamePasswordAuthenticationToken,用AuthenticationManager进行用户名密码校验,如下主要源码执行过程
PreAuthenticatedAuthenticationProvider
通过源码调用链一直到loadUserDetails方法中,最终实现是由我们自己去实现
测试
问题遗留
1.同个用户多次登录时可同时访问
2.对登录接口使用contains判断安全性不高
3.单点登录实现方式