基于数据库自定义UserDetailsService实现JWT认证
SpringSecurity整合JWT,前后端分离
·
我的思路是,登录时使用用户凭证换取Token,Token存储在Redis中,每次请求验证Token与Redis中是否相同并续签,Redis控制Token过期时间。步骤如下:
添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!--身份认证-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
</dependencies>
创建用户类与角色类
用户类应该实现UserDetails
接口以契合SpringSecurity
。
//实现接口时自带方法,注意各个方法的意义,自行修改,默认为True
@Data
public class User implements UserDetails {
private Integer id;
private String phone;
private Boolean enabled;
private String username;
private String password;
private Integer role;
private List<Role> roles;
//JsonIgnore在接口传递时隐藏敏感信息
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
for (Role role:roles)
authorities.add(new SimpleGrantedAuthority(role.getName()));
return authorities;
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
在实际开发中会设置用户角色来控制接口访问,角色类的角色名要求有前缀ROLE_
,例如ADMIN,则时ROLE_ADMIN
。
@Data
public class Role implements GrantedAuthority {
private Integer id;
private String roleName;
@JsonIgnore
@Override
public String getAuthority() {
return roleName;
}
}
Redis配置类
先设置常量,例如过期时间。
public final class ConstantKit {
public static final Integer DEL_FLAG_TRUE=1;
public static final Integer DEL_FLAG_FALSE=0;
/**
* redis存储token设置的过期时间
* 单位:秒(1h)
*/
public static final Long TOKEN_EXPIRE_TIME= Long.valueOf(60*60);
/**
* 设置可以重置token过期时间的时间界限
* 单位:毫秒(30min)
*/
public static final Long TOKEN_RESET_TIME= Long.valueOf(1000*30*60);
}
创建与属性文件映射的配置类。
@Data
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfigProperty {
private String host;
private String password;
private int port;
private int database;
private int timeout;
}
对Jedis进行配置。
public class JedisConfig extends CachingConfigurerSupport {
private static final Logger LOGGER = LoggerFactory.getLogger(JedisConfig.class);
@Resource
private RedisConfigProperty redisConfigProperty;
@Bean(name = "jedisPoolConfig")
@ConfigurationProperties(prefix = "spring.redis.pool-config")
public JedisPoolConfig getRedisConfig(){
return new JedisPoolConfig();
}
@Bean(name = "jedisPool")
public JedisPool jedisPool(@Qualifier(value = "jedisPoolConfig") final JedisPoolConfig jedisPoolConfig){
LOGGER.info("Jedis Pool build start");
String host = redisConfigProperty.getHost();
int timeout = redisConfigProperty.getTimeout();
String password = redisConfigProperty.getPassword();
int database = redisConfigProperty.getDatabase();
int port = redisConfigProperty.getPort();
JedisPool jedisPool = new JedisPool(jedisPoolConfig,host,port,timeout,password,database);
LOGGER.info("Jedis Pool build success host={},port={}",host,port);
return jedisPool;
}
}
JWT生成验证
JWT工具类。
public class JwtUtils {
//PayLoad密钥
private static final String JWT_PAYLOAD_USER_KEY = "CSDN";
/**
* 生成Token并设置过期时间(过期时间不在此处实现,在Redis中实现)
* @param userInfo
* @param privateKey
* @param expire
* @return
*/
public static String generateTokenExpireInMillis(Object userInfo, PrivateKey privateKey,long expire){
return Jwts.builder()
.claim(JWT_PAYLOAD_USER_KEY,JsonUtils.toString(userInfo))
.setId(createJTI())
// .setExpiration(new Date(System.currentTimeMillis()+expire))//毫秒
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
}
/**
* Token解密
* @param token
* @param publicKey
* @return
*/
private static Jws<Claims> parserToken(String token, PublicKey publicKey){
return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token);
}
/**
* 从Token中获取个人信息
* @param token
* @param publicKey
* @param userType
* @return
* @param <T>
*/
public static <T>Payload<T> getInfoFromToken(String token,PublicKey publicKey,Class<T> userType){
Jws<Claims> claimsJws = parserToken(token,publicKey);
Claims body = claimsJws.getBody();
Payload<T> claims = new Payload<>();
claims.setId(body.getId());
claims.setUserinfo(JsonUtils.toBean(body.get(JWT_PAYLOAD_USER_KEY).toString(),userType));
return claims;
}
/**
* 生成ID
* @return
*/
private static String createJTI(){
return new String(Base64.getEncoder().encode(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8)));
}
}
RSA密钥工具类,用来对Token再加密解密。
public class RsaUtils {
private static final int DEFAULT_KEY_SIZE = 2048;
/**
* 从文件中读取公钥
* @param fileName
* @return
* @throws IOException
* @throws NoSuchAlgorithmException
* @throws InvalidKeySpecException
*/
public static PublicKey getPublicKey(String fileName) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
byte[] bytes = readFile(fileName);
return getPublicKey(bytes);
}
/**
* 从文件中读取私钥
* @param fileName
* @return
* @throws IOException
* @throws NoSuchAlgorithmException
* @throws InvalidKeySpecException
*/
public static PrivateKey getPrivateKey(String fileName) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
byte[] bytes = readFile(fileName);
return getPrivateKey(bytes);
}
/**
* 从字节数组中读取公钥
* @param bytes
* @return
* @throws NoSuchAlgorithmException
* @throws InvalidKeySpecException
*/
private static PublicKey getPublicKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
bytes = Base64.getDecoder().decode(bytes);
X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePublic(spec);
}
/**
* 从字节数组中读取私钥
* @param bytes
* @return
* @throws NoSuchAlgorithmException
* @throws InvalidKeySpecException
*/
private static PrivateKey getPrivateKey(byte[] bytes) throws NoSuchAlgorithmException, InvalidKeySpecException {
bytes = Base64.getMimeDecoder().decode(bytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes);
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(spec);
}
/**
* 生成密钥文件
* @param publicKeyFileName
* @param privateKeyFileName
* @param secret
* @param keySize
* @throws IOException
* @throws NoSuchAlgorithmException
*/
public static void generateKey(String publicKeyFileName,String privateKeyFileName,String secret,int keySize) throws IOException, NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(secret.getBytes(StandardCharsets.UTF_8));
keyPairGenerator.initialize(Math.max(keySize,DEFAULT_KEY_SIZE),secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
publicKeyBytes = Base64.getEncoder().encode(publicKeyBytes);
writeFile(publicKeyFileName,publicKeyBytes);
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
privateKeyBytes = Base64.getEncoder().encode(privateKeyBytes);
writeFile(privateKeyFileName,privateKeyBytes);
}
/**
* 读文件
* @param fileName
* @return
* @throws IOException
*/
private static byte[] readFile(String fileName) throws IOException {
return Files.readAllBytes(new File(fileName).toPath());
}
/**
* 写文件
* @param destPath
* @param bytes
* @throws IOException
*/
private static void writeFile(String destPath, byte[] bytes) throws IOException {
File dest = new File(destPath);
if (!dest.exists())
dest.createNewFile();
Files.write(dest.toPath(),bytes);
}
}
RSA配置类。
Data
@ConfigurationProperties("rsa.key")
public class RsaKeyProperties {
private String publicKeyPath;
private String privateKeyPath;
private PublicKey publicKey;
private PrivateKey privateKey;
@PostConstruct
public void createKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
this.publicKey = RsaUtils.getPublicKey(publicKeyPath);
this.privateKey = RsaUtils.getPrivateKey(privateKeyPath);
}
}
配置文件
在resources文件夹下编辑application.properties
。
server.port=8181
# rsa
rsa.key.publicKeyPath=公钥地址
rsa.key.privateKeyPath=私钥地址
#redis 基础配置
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接密码(默认为空)
spring.redis.password=redis
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器连接端口
spring.redis.port=6379
# 连接超时时间(毫秒)
spring.redis.timeout=5000
#redis 连接池配置
#池中最大链接数
spring.redis.pool-config.max-total=256
# 连接池中的最大空闲连接
spring.redis.pool-config.max-idle=128
# 连接池中的最小空闲连接
spring.redis.pool-config.min-idle=8
# 调用者获取链接时,是否检测当前链接有效性
spring.redis.pool-config.test-on-borrow=false
# 向链接池中归还链接时,是否检测链接有效性
spring.redis.pool-config.test-on-return=false
# 调用者获取链接时,是否检测空闲超时, 如果超时,则会被移除-
spring.redis.pool-config.test-while-idle=true
# 空闲链接检测线程一次运行检测多少条链接
spring.redis.pool-config.num-tests-per-eviction-run=8
# 缓存
spring.cache.cache-names=c1,c2
spring.cache.redis.time-to-live=1800s
创建认证失败类
当用户没有有效凭证时,会用此类进行处理。
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
Map<String,Object> resultData = new HashMap<>();
resultData.put("code","401");
resultData.put("msg", "请登录!");
out.write(new ObjectMapper().writeValueAsString(resultData));
out.flush();
out.close();
}
}
创建权限不足类
当用户试图访问未经授权的接口时,会到这里处理。
public class UserAuthAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
Map<String,Object> resultData = new HashMap<>();
resultData.put("code","403");
resultData.put("msg", "未授权");
out.write(new ObjectMapper().writeValueAsString(resultData));
out.flush();
out.close();
}
}
创建过滤器
这里创建两个过滤器,一个用于登录颁发token,一个用于续签鉴定token。
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {;
private AuthenticationManager authenticationManager;
private RsaKeyProperties rsaKeyProperties;
private JedisPool jedisPool;
public JwtLoginFilter(AuthenticationManager authenticationManager, RsaKeyProperties rsaKeyProperties,
JedisPool jedisPool){
this.authenticationManager = authenticationManager;
this.rsaKeyProperties = rsaKeyProperties;
this.jedisPool = jedisPool;
}
//首先执行此函数,如果与数据库中比对不上,则会抛出异常
//为了防止账号枚举,这里只显示账号或密码错误
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
User user = null;
try {
ObjectMapper objectMapper = new ObjectMapper();
user = objectMapper.readValue(request.getInputStream(),User.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
user.getUsername(),
user.userPassword()
)
);
} catch (Exception e) {
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
map.put("message", "账号或密码错误!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
e.printStackTrace();
}catch (Exception exception){
exception.printStackTrace();
}
}
return null;
}
//登录成功
@Override
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authentication){
User user = new User();
user.setUsername(authentication.getName());
user.setRoles((List<Role>) authentication.getAuthorities());
String token = JwtUtils.generateTokenExpireInMillis(user,rsaKeyProperties.getPrivateKey(),10*60*1000);
Jedis jedis = jedisPool.getResource();
response.addHeader("Authorization","AttackToken "+token);
String oldToken = jedis.get(user.getUsername());
if (oldToken!=null)
jedis.del(oldToken,oldToken+user.getUsername());
jedis.set(user.getUsername(),token);
jedis.expire(user.getUsername(), ConstantKit.TOKEN_EXPIRE_TIME);
jedis.set(token,user.getUsername());
jedis.expire(token,ConstantKit.TOKEN_EXPIRE_TIME);
Long currentTime = System.currentTimeMillis();
jedis.set(token+user.getUsername(),currentTime.toString());
jedis.close();
try {
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter out = response.getWriter();
Map<String,Object> map = new HashMap<>(4);
map.put("code",HttpServletResponse.SC_OK);
map.put("message","登陆成功!");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
请求过滤类,对每一个请求的Token进行验证与续签。
public class JwtVerifyFilter extends BasicAuthenticationFilter {
private RsaKeyProperties rsaKeyProperties;
private JedisPool jedisPool;
public JwtVerifyFilter(AuthenticationManager authenticationManager,RsaKeyProperties rsaKeyProperties,
JedisPool jedisPool) {
super(authenticationManager);
this.rsaKeyProperties = rsaKeyProperties;
this.jedisPool = jedisPool;
}
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null||!header.startsWith("AttackToken ")){
//不应该在此处抛出异常,抛出异常会重定向至/error,但/error并未放通
//故会出现只要是出现了异常,如404等都会被AuthenticationEntryPoint捕获从而返回401
// throw new AccessDeniedException("未登录");
chain.doFilter(request,response);
return;
}
String token = header.replace("AttackToken ","");
User user = JwtUtils.getInfoFromToken(token, rsaKeyProperties.getPublicKey(),User.class).getUserinfo();
Jedis jedis = jedisPool.getResource();
request.setAttribute("requestUser",user.getUsername());
if (jedis.get(token) == null)
throw new AccessDeniedException("登录凭证已废弃");
if (user!=null){
Authentication authentication = new UsernamePasswordAuthenticationToken
(user.getUsername(),null,user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
Long tokenBirthTime = Long.valueOf(jedis.get(token+user.getUsername()));
logger.info("token birth time is:"+tokenBirthTime);
Long diff = System.currentTimeMillis()-tokenBirthTime;//时间差
logger.info("token has existed for:"+diff);
if (jedis.get(user.getUsername())==null)
throw new AccessDeniedException("登录过期");
if (diff> ConstantKit.TOKEN_RESET_TIME){
jedis.expire(user.getUsername(), ConstantKit.TOKEN_EXPIRE_TIME);
jedis.expire(token,ConstantKit.TOKEN_EXPIRE_TIME);
logger.info("Reset expire time success");
Long newBirthTime = System.currentTimeMillis();
jedis.set(token+user.getUsername(),newBirthTime.toString());
}
jedis.close();
chain.doFilter(request,response);
}else {
throw new AccessDeniedException("未登录");
}
}
}
自定义UserDetailsService
UsernamePasswordAuthenticationToken
以及DaoAuthenticationProvider
使用UserDetailsService
来查询用户名、密码和GrantedAuthority
,检查用户输入的密码是否匹配。
@Service
public class UserService implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Autowired
RoleMapper roleMapper;
@Override
public UserDetails loadUserByUsername(String username) {
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.eq("username", username);
if (userMapper.selectList(userQueryWrapper).isEmpty()) {
throw new UsernameNotFoundException("用户名不存在");
}
User user = userMapper.selectList(userQueryWrapper).get(0);
//将角色赋予用户
Role role = roleMapper.selectById(user.getRole());
List<Role> roles = new ArrayList<>();
roles.add(roles);
user.setRoles(roles);
return user;
}
}
Spring Security配置类
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserService userService;
private final RsaKeyProperties rsaKeyProperties;
private final JedisPool jedisPool;
public SecurityConfig(UserServiceImpl userServiceImpl, RsaKeyProperties rsaKeyProperties, JedisPool jedisPool) {
this.userServiceImpl = userServiceImpl;
this.rsaKeyProperties = rsaKeyProperties;
this.jedisPool = jedisPool;
}
private String[] loadExcludePath() {
return new String[]{
"/static/**",
"/templates/**",
"/img/**",
"/js/**",
"/css/**",
"/lib/**"
};
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception {
AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userServiceImpl)
.passwordEncoder(passwordEncoder())
.and()
.build();
return authenticationManager;
}
@Bean
public SecurityFilterChain securityFilterChain(AuthenticationManager authenticationManager, HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf().disable()
.authorizeRequests()
//放通所有静态资源
.antMatchers(loadExcludePath()).permitAll()
//放通注册
.antMatchers(HttpMethod.POST,"/user/add").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/logger/**").hasAnyRole("ADMIN","LOGGER")
.antMatchers("/user/**").hasAnyRole("ADMIN","LOGGER","USER")
//其余请求都需要认证后访问
.anyRequest()
.authenticated()
.and()
.addFilter(new JwtLoginFilter(authenticationManager,rsaKeyProperties, jedisPool))
.addFilter(new JwtVerifyFilter(authenticationManager, rsaKeyProperties,jedisPool))
//已认证但是权限不够
.exceptionHandling().accessDeniedHandler(new UserAuthAccessDeniedHandler())
.and()
//未能通过认证,也就是未登录
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);//禁用session
return httpSecurity.build();
}
}
注意事项
- 在数据库中的角色名应该为ROLE_ADMIN,ROLE_LOGGER,ROLE_USER。
- 本文对密码进行了加盐加密处理,请先对密码加密后再存储到数据库,才能比对成功,加密示例如下
String password = "password";
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encodedPassword = bCryptPasswordEncoder.encode(password);
System.out.println("encodedPassword");
- 本文省略了诸多细节,例如Redis、MyBatisPlus等,无法直接使用。
更多推荐
已为社区贡献1条内容
所有评论(0)