WA_automat

SpringSecurity:单点登录

N 人看过

写了很多次单点登录但是每次写都得回去看一遍,老是会忘记,所以决定在这里写篇博客(方便

本篇博客用到的技术栈:

  1. 框架
  2. 框架

工具类

先给出我的生成的工具类叭:

(记得自行修改这个变量,这是用来控制生成的密钥)

package com.example.onlinespring.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

@Component
public class JwtUtil {
    public static final long JWT_TTL = 60 * 60 * 1000L * 24 * 3;  // 有效期3天
    public static final String JWT_KEY = "myjwtkey";

    public static String getUUID() {
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    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) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        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()
                .setId(uuid)
                .setSubject(subject)
                .setIssuer("sg")
                .setIssuedAt(now)
                .signWith(signatureAlgorithm, secretKey)
                .setExpiration(expDate);
    }

    public static SecretKey generalKey() {
        byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        return new SecretKeySpec(encodeKey, 0, encodeKey.length, "HmacSHA256");
    }

    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(jwt)
                .getBody();
    }
}

构建RBAC权限控制模型

  1. 用户表
  2. 角色表
  3. 权限表
  4. 用户-角色关联表
  5. 角色-权限关联表

用户实体类

package com.example.onlinespring.pojo;

import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;

/**
 * 用户实体类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("user")
public class User {

	@TableId(type = IdType.AUTO)
	private Integer id;

	@NonNull
	@TableField("username")
	private String username;

	@NonNull
	@TableField("email")
	private String email;

	@NonNull
	@TableField("password")
	private String password;

	@NonNull
	@TableField("gender")
	private Integer gender;

	@NonNull
	@TableField("education")
	private Integer education;

	@NonNull
	@TableField("age")
	private Integer age;

	@NonNull
	@TableField("isNormal")
	private Integer isNormal;

	@NonNull
	@TableField("major")
	private String major;

	@NonNull
	@TableField("avatar")
	private String avatar;

	// 标记此属性为version列对应的属性
	@Version
	@TableField("version")
	private Integer version;

}

用户映射类

package com.example.onlinespring.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.onlinespring.pojo.User;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserMapper extends BaseMapper<User> {
	List<String> selectMenuByUserId(Integer userId);
	void insertRoleByUserId(Integer userId, Integer roleId);
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.onlinespring.mapper.UserMapper">
    <insert id="insertRoleByUserId">
        INSERT INTO sys_u_r
        (userId, roleId)
        VALUES (#{userId}, #{roleId})
    </insert>
    <select id="selectMenuByUserId" resultType="java.lang.String">
        SELECT m.menuKey
        FROM user u
                 LEFT JOIN sys_u_r ur ON u.id = ur.userId
                 LEFT JOIN role r ON r.id = ur.roleId
                 LEFT JOIN sys_r_m rm ON r.id = rm.roleId
                 LEFT JOIN menu m ON m.id = rm.menuId
        WHERE u.id = #{userId}
    </select>
</mapper>

这是用来封装用户信息与权限信息的实体类对象。

可以使用不同的字段来作为,从而实现登录时的用户名不同。

package com.example.onlinespring.pojo;

import com.alibaba.fastjson2.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
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;

/**
 * LoginUser类
 * 实现UserDetails接口
 *
 * @author WA_automat
 * @Since 1.0
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {

	// 私有成员User
	private User user;

	// 封装权限的字符串列表permissions
	private List<String> permissions;

	// 权限信息
	@JSONField(serialize = false)
	private List<SimpleGrantedAuthority> authorities;

	public LoginUser(User user, List<String> permissions) {
		this.user = user;
		this.permissions = permissions;
	}

	/**
	 * 获取权限集合
	 *
	 * @return 权限集合
	 */
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		if (authorities != null) {
			return authorities;
		}

		// 将permissions中的字符串封装成权限信息
		authorities = permissions.stream()
				.map(SimpleGrantedAuthority::new)
				.collect(Collectors.toList());
		return authorities;
	}

	/**
	 * 获取用户密码
	 * 调用User类的获取用户密码函数即可
	 *
	 * @return 用户密码
	 */
	@Override
	public String getPassword() {
		return user.getPassword();
	}

	/**
	 * 获取用户名
	 * 调用User类的获取用户名函数即可
	 *
	 * @return 用户名
	 */
	@Override
	public String getUsername() {
		return user.getEmail();
	}

	// 下面是各种判断,改为返回true即可

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}
}

用于在的过滤器中,将用户包装成形式返回。

package com.example.onlinespring.service.Impl.user;


import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.onlinespring.mapper.UserMapper;
import com.example.onlinespring.pojo.LoginUser;
import com.example.onlinespring.pojo.User;
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.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

	@Autowired
	private UserMapper userMapper;

	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		// 使用UserMapper查询用户信息
		LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
		queryWrapper.eq(User::getEmail, email);
		User user = userMapper.selectOne(queryWrapper);

		// 判断获取的user是否为空
		// 如果为空则抛出异常
		if (Objects.isNull(user)) {
			throw new RuntimeException("邮箱或密码错误");
		}

		// 查询用户的权限信息
		// List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
		List<String> list = userMapper.selectMenuByUserId(user.getId());

		// 把数据封装成UserDetails对象返回
		return new LoginUser(user, list);
	}
}

登录与登出接口

登录

    @Override
	@Async("asyncThreadPoolTaskExecutor")
	public Future<ResponseResult> login(String email, String password) {

		// AuthenticationManager 进行用户认证
		UsernamePasswordAuthenticationToken authenticationToken =
				new UsernamePasswordAuthenticationToken(email, password);

		Authentication authenticate = authenticationManager.authenticate(authenticationToken);

		// 如果认证没通过,给出对应提示
		if (Objects.isNull(authenticate)) {
			throw new RuntimeException("登录失败");
		}

		// 如果认证通过,使用userid生成有一个jwt,jwt存入ResponseResult返回
		LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
		User user = loginUser.getUser();
		String userid = user.getId().toString();
		String jwt = JwtUtil.createJWT(userid);

		// 将token响应给前端
		Map<String, String> map = new HashMap<>();
		map.put("token", jwt);

		// 更换为Jedis
		// 用户信息存入redis
		Jedis jedis = new Jedis(host, Math.toIntExact((port)));
		jedis.connect();
		jedis.set("login:" + userid, JSON.toJSONString(loginUser));
		// 登录有效期最长为7天(即7天后必须重新登录)
		jedis.expire("login:" + userid, 7 * 24 * 60 * 60L);
//		redisCache.setCacheObject("login:" + userid, loginUser);

		// 获取用户名
		map.put("username", user.getUsername());

		return AsyncResult.forValue(new ResponseResult<>(200, "登录成功", map));
	}

登出

   @Override
public ResponseResult logout() {
	// 获取SecurityContextHolder中的用户id
	UsernamePasswordAuthenticationToken authentication =
			(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
	LoginUser loginUser = (LoginUser) authentication.getPrincipal();
	Integer userid = loginUser.getUser().getId();

	// 删除redis中的值
	Jedis jedis = new Jedis(host, Math.toIntExact((port)));
	jedis.connect();
	jedis.del("login:" + userid);

	return new ResponseResult(200, "注销成功", null);
}

过滤器

用于判断前端传过来的是否正确有效,用以过滤掉一些请求。

package com.example.onlinespring.filter;

import com.alibaba.fastjson2.JSON;
import com.example.onlinespring.pojo.LoginUser;
import com.example.onlinespring.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import redis.clients.jedis.Jedis;

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.Objects;

/**
 * 一个Jwt认证过滤器
 *
 * @author WA_automat
 * @since 1.0
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

	@Value("${spring.redis.host}")
	private String host;

	@Value("${spring.redis.port}")
	private Long port;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

		// 获取Token
		String token = request.getHeader("token");

		// 获取不到Token时
		if (!StringUtils.hasText(token)) {
			// 放行
			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 非法");
		}

		// 根据Token获取用户信息
		String redisKey = "login:" + userid;

		// 更换为Jedis
		Jedis jedis = new Jedis(host, Math.toIntExact((port)));
		jedis.connect();
		LoginUser loginUser = JSON.parseObject(jedis.get(redisKey), LoginUser.class);
		if (Objects.isNull(loginUser)) {
			throw new RuntimeException("用户未登录");
		}

		// 存入SecurityContextHolder
		UsernamePasswordAuthenticationToken authenticationToken =
				new UsernamePasswordAuthenticationToken(
						loginUser,
						null,
						loginUser.getAuthorities()
				);

		SecurityContextHolder.getContext().setAuthentication(authenticationToken);

		// 放行
		filterChain.doFilter(request, response);
	}
}

自定义错误类

主要是认证和授权失败的两个类,以及一个对应的工具类。

package com.example.onlinespring.utils;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * web工具类
 * 暂时作为放行静态资源的工具
 * 后续可能继续添加功能
 */
public class WebUtil {
	public static void renderString(HttpServletResponse response, String string) {
		response.setStatus(200);
		response.setContentType("application/json");
		response.setCharacterEncoding("utf-8");
		try {
			response.getWriter().println(string);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}
package com.example.onlinespring.handler;

import com.alibaba.fastjson2.JSON;
import com.example.onlinespring.utils.ResponseResult;
import com.example.onlinespring.utils.WebUtil;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * 认证异常实现类
 */
@Component
public class AuthenticationEntryPointImpl
		implements AuthenticationEntryPoint {
	@Override
	public void commence(
			HttpServletRequest request,
			HttpServletResponse response,
			AuthenticationException authException
	) throws IOException, ServletException {
		// 处理异常
		Map<String, String> map = new HashMap<>();
		map.put("state", "error");
		ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录", map);
		String json = JSON.toJSONString(result);
		WebUtil.renderString(response, json);
	}
}
package com.example.onlinespring.handler;

import com.alibaba.fastjson2.JSON;
import com.example.onlinespring.utils.ResponseResult;
import com.example.onlinespring.utils.WebUtil;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * 权限异常实现类
 */
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
	@Override
	public void handle(
			HttpServletRequest request,
			HttpServletResponse response,
			AccessDeniedException accessDeniedException
	) throws IOException, ServletException {
		// 处理异常
		Map<String, String> map = new HashMap<>();
		map.put("state", "error");
		ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "您的权限不足", map);
		String json = JSON.toJSONString(result);
		WebUtil.renderString(response, json);
	}
}

最重要的配置类

用于生成密码加密器和认证授权管理器,还有放行静态资源与公有接口的类。

package com.example.onlinespring.config;

import com.example.onlinespring.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {

	@Autowired
	private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

	@Autowired
	private AuthenticationEntryPoint authenticationEntryPoint;

	@Autowired
	private AccessDeniedHandler accessDeniedHandler;

	/**
	 * 创建密码加密工具
	 *
	 * @return BCryptPasswordEncoder
	 * @author WA_automat
	 * @since 1.0
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	/**
	 * 暴露AuthenticationManager
	 *
	 * @author WA_automat
	 * @since 1.0
	 */
	@Bean
	public AuthenticationManager authenticationManager(
			AuthenticationConfiguration authenticationConfiguration
	) throws Exception {
		return authenticationConfiguration.getAuthenticationManager();
	}

	/**
	 * HttpSecurity的配置
	 *
	 * @param httpSecurity 参数
	 * @return SecurityFilterChain 过滤链
	 * @throws Exception 异常
	 */
	@Bean
	SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

		// httpSecurity配置
		httpSecurity
				.csrf().disable()
				.authorizeRequests()
				// OPTIONS放行
				.antMatchers(HttpMethod.OPTIONS,"/**").permitAll()
				.and()
				.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
				.and()
				.authorizeRequests()
				//下边的路径放行
				.antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
						"/swagger-resources", "/swagger-resources/configuration/security",
						"/swagger-ui.html", "/webjars/**", "/static/images/**").permitAll()
				// 放行接口
				.antMatchers(
						"/user/register",
						"/user/login",
						"/user/reset/password",
						"/checkcode/register/code",
						"/checkcode/reset/code"
				).anonymous()
				.anyRequest().authenticated();

		// 配置认证过滤器
		httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

		// 配置异常处理器
		httpSecurity.exceptionHandling()
				// 配置认证失败处理器
				.authenticationEntryPoint(authenticationEntryPoint)
				// 配置授权失败处理器
				.accessDeniedHandler(accessDeniedHandler);

		// 返回过滤链
		return httpSecurity.build();
	}

}

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。