Security系列教程
- ①Security入门体验
- ②Security自定义账号密码验证+thymeleaf登录案例(附带网页案例
- ③Security+验证码登录案例(附带网页案例)
- ④Security+jwt 实现无状态认证,前后端分离(附带网页案例))
文章目录
简介
本章主要实现Spring Security自定义用户认证功能,jwt 实现token无状态认证。
- 使用JWT来传输数据,实际上传输的是一个字符串,这个字符串就是所谓的json web token字符串。
- jwt认证和传统的session和cookie模式相比,由于token认证实现了无状态,不再依赖session和cookie,所以无需要考虑伪造cookie等跨域攻击。
1. 环境
- Springboot版本
2.5.3
- 新增
hutool
工具包,用于生成jwt
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- json 工具包 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- hu-tool工具包 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.22</version>
</dependency>
yml
中配置jwt自定义秘钥和有效期
server:
port: 9999
jwt:
# 密钥
secret: xxxxx.xxxx.xxxx
# 有效期(秒)
expire: 86400
2. 封装JWT工具类
- 封装JWT工具类,用于生成和校验token
- jwt认证和传统的session模式不同,由于是无状态认证,所以每次请求都需要进行一次认证,这里最好把用户角色和权限直接写进token中,或放入redis缓存中管理,以免每次都去数据库查询角色和权限,影响服务器性能。
- 本案例选择把角色和权限直接写入token
@Slf4j
@Component
public class JwtProvider {
@Value("${jwt.expire}")
private Integer expire;
@Value("${jwt.secret}")
private String secret;
public static final String TOKEN_HEADER = "token";
public static final String AUTHORITY = "authority";
/**
* 生成token
*
* @param userId 用户id
*/
public String createToken(Object userId) {
return createToken(userId, null, null);
}
/**
* 生成token,我们把用户id和授权信息都放入token中,
* 无状态下每次请求都是会校验token的,这样做就不用每次校验都去查数据库了
*
* @param userId 用户id
* @param roles 角色集合
* @param auths 权限集合
*/
public String createToken(Object userId, List<String> roles, List<String> auths) {
ArrayList<String> authorityList = new ArrayList<>();
// 合并角色和权限
Optional.ofNullable(roles).ifPresent(authorityList::addAll);
Optional.ofNullable(auths).ifPresent(authorityList::addAll);
// 数组转String
String authorityStr = String.join(",", authorityList);
// 设置token有效期
Date validity = new Date((new Date()).getTime() + expire * 1000);
return JWT.create()
// 秘钥
.setKey(this.getSecretKey())
// 代表这个JWT的主体,即它的所有人
.setSubject(String.valueOf(userId))
// 是一个时间戳,代表这个JWT的签发时间;
.setIssuedAt(new Date())
// 放入用户id
.setPayload("userId", userId)
// 放入角色和权限
.setPayload(AUTHORITY, authorityStr)
// 有效期
.setExpiresAt(validity)
.sign();
}
/**
* 校验token
*/
public boolean validateToken(String authToken) {
try {
return JWT.of(authToken).setKey(this.getSecretKey()).validate(0);
} catch (Exception e) {
log.error("无效的token:" + authToken);
}
return false;
}
/**
* 解码token
*/
public JWT decodeToken(String token) {
if (validateToken(token)) {
JWT jwt = JWT.of(token).setKey(this.getSecretKey());
// 客户端id
Object clientId = jwt.getPayload("clientId");
// 用户id
Object userId = jwt.getPayload("userId");
log.info("token有效,userId:{}", userId);
return jwt;
}
log.error("***token无效***");
return null;
}
private byte[] getSecretKey() {
return secret.getBytes(StandardCharsets.UTF_8);
}
}
3. Security中添加JWT过滤器
- 重写
BasicAuthenticationFilter
- 从请求头中取出token,进行认证,通过后放行
- 由于我们的token已经存储了用户的角色和权限,所以这里我们直接从token中解析出用户角色和权限放入认证管理器
@Slf4j
public class JwtFilter extends BasicAuthenticationFilter {
private final JwtProvider jwtProvider;
public JwtFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider) {
super(authenticationManager);
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = request.getHeader(JwtProvider.TOKEN_HEADER);
// 这里如果没有jwt,继续往后走,因为后面还有鉴权管理器等去判断是否拥有身份凭证,所以是可以放行的
// 注意登录等放行接口请求头不可带token,否则会进来认证
if (StrUtil.isBlankOrUndefined(token)) {
chain.doFilter(request, response);
return;
}
JWT jwt = jwtProvider.decodeToken(token);
if (jwt == null) {
throw new JWTException("token 异常");
}
// 获取角色和权限
List<GrantedAuthority> authority = this.getAuthority(jwt);
// 构建UsernamePasswordAuthenticationToken,这里密码为null,是因为提供了正确的JWT,实现自动登录
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(jwt, null, authority);
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
chain.doFilter(request, response);
}
/**
* 从token中获取用户权限
*/
private List<GrantedAuthority> getAuthority(JWT jwt) {
String authsStr = (String) jwt.getPayload(JwtProvider.AUTHORITY);
if (!StrUtil.isBlank(authsStr)) {
// 角色和权限都在这里添加,角色以ROLE_前缀,不是ROLE_前缀的视为权限
return AuthorityUtils.commaSeparatedStringToAuthorityList(authsStr);
}
return null;
}
}
注意:
放行接口请求头不可带token,否则会进来认证,导致放行失败。
4. 重写Security的处理器
- 由于前后端分离,这里我们先封装一下通用响应
@EqualsAndHashCode(callSuper = false)
@Data
@Accessors(chain = true)
public class ResponseResult<T> implements Serializable {
private static final long serialVersionUID = -1L;
private Integer code;
private String message;
private T data;
public ResponseResult(Integer code, String message, T data) {
super();
this.code = code;
this.message = message;
this.data = data;
}
private static <T> ResponseResult<T> build(Integer code, String message, T data) {
return new ResponseResult<>(code, message, data);
}
public static <T> ResponseResult<T> ok() {
return new ResponseResult<>(RespCode.OK.code, RespCode.OK.message, null);
}
public static <T> ResponseResult<T> ok(T data) {
return build(RespCode.OK.code, RespCode.OK.message, data);
}
public static <T> ResponseResult<T> fail() {
return fail(RespCode.ERROR.message);
}
public static <T> ResponseResult<T> fail(String message) {
return fail(RespCode.ERROR, message);
}
public static <T> ResponseResult<T> fail(RespCode respCode) {
return fail(respCode, respCode.message);
}
public static <T> ResponseResult<T> fail(RespCode respCode, String message) {
return build(respCode.getCode(), message, null);
}
public enum RespCode {
/**
* 业务码
*/
OK(20000, "请求成功"),
MY_ERROR(20433, "自定义异常"),
UNAUTHORIZED(20401, "未授权"),
LOGIN_FAIL(20402, "账号或密码错误"),
ERROR(20400, "未知异常");
RespCode(int code, String message) {
this.code = code;
this.message = message;
}
private final int code;
private final String message;
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
}
- 授权处理器,用于处理无权访问
@Component
@RequiredArgsConstructor
@Slf4j
public class AccessFailure implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException {
log.info("拒绝访问,无权限");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONObject.toJSONString(ResponseResult.fail(ResponseResult.RespCode.UNAUTHORIZED)));
}
}
- JWT认证处理器,处理认证失败
@Component
@Slf4j
public class JwtAuthFailure implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.info("认证失败,token不存在或已失效");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONObject.toJSONString(ResponseResult.fail(ResponseResult.RespCode.UNAUTHORIZED)));
}
}
- 退出登录处理器,处理退出登录成功
@Slf4j
@Component
public class LogoutSuccess extends SimpleUrlLogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONObject.toJSONString(ResponseResult.ok().setMessage("退出登录成功!")));
}
}
5. Security的核心配置
@EnableWebSecurity
启动Security的默认功能@EnableGlobalMethodSecurity(prePostEnabled = true)
开启权限注解控制- 重写
WebSecurityConfigurerAdapter
,注册我们前面定义的过滤器
和处理器
,添加拦截规则,关闭跨域。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final SimpleUrlLogoutSuccessHandler logoutSuccess;
private final JwtAuthFailure authFailure;
private final AccessFailure accessFailure;
private final JwtProvider jwtProvider;
/**
* 白名单
*/
public final static String[] AUTH_WHITELIST = {"/login"};
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.logout()
.logoutSuccessHandler(logoutSuccess)
// 禁用session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated()
// 异常处理器
.and()
.exceptionHandling()
// jwt认证失败
.authenticationEntryPoint(authFailure)
// 拒绝访问
.accessDeniedHandler(accessFailure)
// 配置自定义的过滤器
.and()
.addFilter(new JwtFilter(authenticationManager(), jwtProvider))
// 验证码过滤器放在UsernamePassword过滤器之前
// .addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
// 关闭跨域
.cors().and().csrf().disable();
}
@Override
public void configure(WebSecurity web) {
// 配置静态文件不需要认证
web.ignoring().antMatchers("/static/**");
}
/**
* 指定加密方式
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
6. 登录和授权实现
- 由于我们是无状态登录,所以不需要再用Security的自带的登录了,也不用重写
UserDetailsService
这么麻烦。 - 封装一下工具类,用于获取登录者信息,密码MD5加密等
public class SecurityUtils {
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
/**
* 获取登录者的信息
*/
public static JWT getInfo() {
return (JWT) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
/**
* 获取登录者的id
*/
public static Object getUserId() {
JWT info = getInfo();
return info.getPayload("userId");
}
/**
* 获取登录者的权限
*/
public static String getAuths() {
return (String) getInfo().getPayload(JwtProvider.AUTHORITY);
}
/**
* 密码加密
*
* @param password 明文密码
* @return 加密后的密码
*/
public static String passwordEncoder(String password) {
return PASSWORD_ENCODER.encode(password);
}
/**
* 密码比对
*
* @param rawPassword 明文密码
* @param encodedPassword 加密后的密码
* @return 是否通过
*/
public static boolean passwordMatches(CharSequence rawPassword, String encodedPassword) {
return PASSWORD_ENCODER.matches(rawPassword, encodedPassword);
}
}
- 模拟一个用户
@Data
@Accessors(chain = true)
public class MyUser {
/**
* id
*/
private Long userId;
/**
* 账号
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 角色
*/
private List<String> roles;
/**
* 权限
*/
private List<String> auths;
}
- 接口
public interface UserService {
/**
* 获取用户
*
* @param username 账号
* @return user
*/
MyUser getUser(String username);
}
- 实现类,这里为了方便测试,就不用数据库了
- 用
HashMap
代替数据库,我们这里模拟了一个admin
用户, 拥有ROLE_ADMIN
角色和read
、write
权限
@Service
public class UserServiceImpl implements UserService {
/**
* 模拟一个数据库用户
* 账号 admin
* 密码 123456
*/
private final static HashMap<String, MyUser> USER_MAP = new LinkedHashMap<>() {
{
put("admin", new MyUser()
.setUserId(1L)
.setUsername("admin")
.setPassword(SecurityUtils.passwordEncoder("123456"))
// 角色以ROLE_前缀
.setRoles(Arrays.asList("ROLE_ADMIN"))
// 权限
.setAuths(Arrays.asList("read", "write"))
);
}
};
@Override
public MyUser getUser(String username) {
return USER_MAP.get(username);
}
}
- 登录接口
@RestController
@RequiredArgsConstructor
@Slf4j
@CrossOrigin(origins = "*")
public class IndexController {
private final JwtProvider jwtProvider;
private final UserService userService;
/**
* 登录
*/
@PostMapping(value = "/login")
public ResponseResult<String> login(@RequestParam("username") String username,
@RequestParam("password") String password) {
MyUser user = userService.getUser(username);
if (Objects.nonNull(user) && SecurityUtils.passwordMatches(password, user.getPassword())) {
String token = jwtProvider.createToken(user.getUserId(), user.getRoles(), user.getAuths());
return ResponseResult.ok(token);
}
return ResponseResult.fail("账号或密码错误");
}
/**
* 获取用户信息
*/
@GetMapping("/info")
@PreAuthorize("hasRole('ROLE_ADMIN') or hasAuthority('read')")
public ResponseResult<HashMap<String, Object>> info() {
return ResponseResult.ok(new HashMap<>(1) {
{
put("userId", SecurityUtils.getUserId());
put("auths", SecurityUtils.getAuths());
}
});
}
@GetMapping("/test")
@PreAuthorize("hasRole('xxx') or hasAuthority('aaa')")
public ResponseResult<String> test() {
return ResponseResult.ok();
}
@GetMapping("/test1")
@PreAuthorize("hasAuthority('write')")
public ResponseResult<String> test1() {
return ResponseResult.ok();
}
@GetMapping("/test2")
@PreAuthorize("hasAuthority('read')")
public ResponseResult<String> test2() {
return ResponseResult.ok();
}
}
7. 效果演示
- 网页源码在结尾有提供,网页在项目根目录下html文件夹中,浏览器直接点击即可运行。
8. 源码分享
本系列项目已收录
Springboot、SpringCloud全家桶教程+源码,各种常用框架使用案例都有哦,具备完善的文档,致力于让开发者快速搭建基础环境并让应用跑起来,并提供丰富的使用示例供使用者参考,快来看看吧。