avatar

SpringBoot项目中使用SpringSecurity和JWT做权限认证

背景

前段时间做了一个项目, 因为涉及到权限认证, 所以分别调研了 SpringSecurity 和 Apache Shiro. 最后选择使用了 SpringSecurity + JWT做权限认证, 现在项目已经结束, 总相关笔记.
项目下载地址 jwt-demo

  1. 使用JWT生成token
  2. token存储在数据库中
  3. 使用 application/json 登录
  4. 使用手机号进行登录
  5. URI动态拦截

配置过程

添加依赖

  1. 分别添加 SpringSecurity JWT 和 fastjson 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!--json-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.60</version>
</dependency>

基础准备对象

  • 主要是在用户登录成功handle时使用JWT生成Token返回给客户端.

基础使用dto

请求返回基类

@Data
public class BaseReqDto implements Serializable {

private String version;

}

@Data
public class BaseRespDto implements Serializable {

private String resultCode;

private String resultMsg;

private String resultTime;

}

登录请求返回对象

@Data
public class LoginReqDto {

private String username;

private String token;

}

@Data
public class LoginRespDto extends BaseRespDto {

private String token;

}

用于验证的用户

package com.liuzhihang.demo.bean;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serializable;
import java.util.Collection;

/**
* 用户信息校验验证码
*
* @author liuzhihang
*/
public class UserDetailsImpl implements UserDetails, Serializable {
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 权限集合
*/
private Collection<? extends GrantedAuthority> authorities;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}

public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}

@Override
public String getPassword() {
return this.password;
}

@Override
public String getUsername() {
return this.username;
}

public void setUsername(String username) {
this.username = username;
}

public void setPassword(String password) {
this.password = password;
}

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

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

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

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

用户未登录handle


/**
* 用户登录认证, 未登录返回信息
*
* @author liuzhihang
* @date 2019-06-04 13:52
*/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {

response.setContentType("application/json;charset=UTF-8");

LoginRespDto respDto = new LoginRespDto();
respDto.setResultCode("0001");
respDto.setResultMsg("用户未登录");
respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

response.getWriter().write(JSON.toJSONString(respDto));
}
}

用户登录验证失败handle

/**
* 用户登录认证失败返回的信息
*
* @author liuzhihang
* @date 2019-06-04 13:57
*/
@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {

response.setContentType("application/json;charset=UTF-8");

LoginRespDto respDto = new LoginRespDto();
respDto.setResultCode("0001");
respDto.setResultMsg("用户登录认证失败");
respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

response.getWriter().write(JSON.toJSONString(respDto));
}
}

用户无权访问handle

/**
* 当用户访问无权限页面时, 返回信息
*
* @author liuzhihang
* @date 2019-06-04 14:03
*/
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {

response.setContentType("application/json;charset=UTF-8");

LoginRespDto respDto = new LoginRespDto();
respDto.setResultCode("0002");
respDto.setResultMsg("用户无权访问");
respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

response.getWriter().write(JSON.toJSONString(respDto));

}
}

用户登录成功handle


/**
* 用户登录成功之后的返回信息
*
* @author liuzhihang
* @date 2019-06-04 14:20
*/
@Slf4j
@Component
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

@Resource
private JwtTokenUtil jwtTokenUtil;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {

UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

String jwtToken = jwtTokenUtil.generateToken(userDetails);

// 把生成的token更新到数据库中
// 更新DB操作 ...

response.setContentType("application/json;charset=UTF-8");

LoginRespDto respDto = new LoginRespDto();
respDto.setToken(jwtToken);
respDto.setResultCode("0000");
respDto.setResultMsg("登录成功");
respDto.setResultTime(LocalDateTime.now().format(FORMATTER));

response.getWriter().write(JSON.toJSONString(respDto));

}
}

JwtTokenUtil

主要用来生成token和通过token解析对象等操作.

package com.liuzhihang.demo.utils;

import com.liuzhihang.demo.bean.UserDetailsImpl;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.Date;

/**
* 使用 java-jwt jwt类库
*
* @author liuzhihang
* @date 2019-06-05 09:22
*/
@Component
public class JwtTokenUtil {

private static final SignatureAlgorithm SIGN_TYPE = SignatureAlgorithm.HS256;

public static final String SECRET = "jwt-secret";

/**
* JWT超时时间
*/
public static final long EXPIRED_TIME = 7 * 24 * 60 * 60 * 1000L;

/**
* claims 为自定义的私有声明, 要放在前面
* <p>
* 生成token
*/
public String generateToken(UserDetails userDetails) {

long instantNow = Instant.now().toEpochMilli();

Claims claims = Jwts.claims();
claims.put(Claims.SUBJECT, userDetails.getUsername());

return Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
.setExpiration(new Date(instantNow + EXPIRED_TIME))
.signWith(SIGN_TYPE, SECRET).compact();
}

/**
* claims 为自定义的私有声明, 要放在前面
* <p>
* 生成token
*/
public String generateToken(String userName) {

long instantNow = Instant.now().toEpochMilli();

Claims claims = Jwts.claims();
claims.put(Claims.SUBJECT, userName);

return Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
.setExpiration(new Date(instantNow + EXPIRED_TIME))
.signWith(SIGN_TYPE, SECRET).compact();
}

/**
* 将token解析, 映射为 UserDetails
*
* @param jwtToken
* @return
*/
public UserDetails getUserDetailsFromToken(String jwtToken) {

Claims claimsFromToken = getClaimsFromToken(jwtToken);

String userName = claimsFromToken.get(Claims.SUBJECT, String.class);

UserDetailsImpl userDetails = new UserDetailsImpl();
userDetails.setUsername(userName);

return userDetails;
}

/**
* 验证token
*/
public Boolean validateToken(String token, UserDetails userDetails) {
UserDetailsImpl user = (UserDetailsImpl) userDetails;
String username = getPhoneNoFromToken(token);

return (username.equals(user.getUsername()) && !isTokenExpired(token));
}

/**
* 刷新令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);

long instantNow = Instant.now().toEpochMilli();

refreshedToken = Jwts.builder().setClaims(claims).setIssuedAt(new Date(instantNow))
.setExpiration(new Date(instantNow + EXPIRED_TIME))
.signWith(SIGN_TYPE, SECRET).compact();
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}

/**
* 获取token是否过期
*/
public Boolean isTokenExpired(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}

/**
* 根据token获取username
*/
public String getPhoneNoFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}

/**
* 获取token的过期时间
*/
public Date getExpirationDateFromToken(String token) {
return getClaimsFromToken(token).getExpiration();
}

/**
* 解析JWT
*/
private Claims getClaimsFromToken(String token) {
return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
}

}

WebSecurityConfig 核心配置

package com.liuzhihang.demo.config;

import com.liuzhihang.demo.filter.CustomizeAuthenticationFilter;
import com.liuzhihang.demo.filter.JwtPerTokenFilter;
import com.liuzhihang.demo.service.UserDetailServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
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.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.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.annotation.Resource;

/**
* @author liuzhihang
* @date 2019-06-03 14:25
*/
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailServiceImpl userDetailServiceImpl;

@Resource
private JwtPerTokenFilter jwtPerTokenFilter;

@Resource(name = "authenticationEntryPointImpl")
private AuthenticationEntryPoint authenticationEntryPoint;

@Resource(name = "authenticationSuccessHandlerImpl")
private AuthenticationSuccessHandler authenticationSuccessHandler;

@Resource(name = "authenticationFailureHandlerImpl")
private AuthenticationFailureHandler authenticationFailureHandler;

@Resource(name = "accessDeniedHandlerImpl")
private AccessDeniedHandler accessDeniedHandler;

/**
* 创建用于认证授权的用户
*
* @param auth
* @throws Exception
*/
@Autowired
public void configureUserInfo(AuthenticationManagerBuilder auth) throws Exception {

// 放入自己的认证授权用户, 内部逻辑需要自己实现
// UserDetailServiceImpl implements UserDetailsService
auth.userDetailsService(userDetailServiceImpl);

}

@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 使用JWT, 关闭session
.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

.and().httpBasic().authenticationEntryPoint(authenticationEntryPoint)

// 登录的权限, 成功返回信息, 失败返回信息
.and().formLogin().permitAll()

.loginProcessingUrl("/login")

// 配置url 权限 antMatchers: 匹配url 权限
.and().authorizeRequests()
.antMatchers("/login", "/getVersion")
.permitAll()
// 其他需要登录才能访问
.anyRequest().access("@dynamicAuthorityService.hasPermission(request,authentication)")

// 访问无权限 location 时
.and().exceptionHandling().accessDeniedHandler(accessDeniedHandler)

// 自定义过滤
.and().addFilterAt(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtPerTokenFilter, UsernamePasswordAuthenticationFilter.class)

.headers().cacheControl();

}


/**
* 密码加密器
*/
@Bean
public PasswordEncoder passwordEncoder() {
/**
* BCryptPasswordEncoder:相同的密码明文每次生成的密文都不同,安全性更高
*/
return new BCryptPasswordEncoder();
}

@Bean
CustomizeAuthenticationFilter customAuthenticationFilter() throws Exception {
CustomizeAuthenticationFilter filter = new CustomizeAuthenticationFilter();
filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
filter.setAuthenticationFailureHandler(authenticationFailureHandler);
filter.setAuthenticationManager(authenticationManagerBean());
return filter;
}

}

登录校验过程

graph TD;
    A(请求登录) --> B(CustomizeAuthenticationFilter#attemptAuthentication 解析请求的json);
    B --> C(UserDetailServiceImpl#loadUserByUsername 验证用户名密码);
    C --> D(AuthenticationSuccessHandlerImpl#onAuthenticationSuccess 构建返回参数 包括token);
    D --> E(返回结果)

自定义拦截器解析 json 报文

前端请求登录报文类型为 application/json 需要后端增加拦截器, 对登录请求报文进行解析

package com.liuzhihang.demo.filter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONException;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

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

/**
*
* 自定义拦截器, 重写UsernamePasswordAuthenticationFilter 从而可以处理 application/json 中的json请求报文
*
* @author liuzhihang
* @date 2019-06-12 19:04
*/
@Slf4j
public class CustomizeAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {

// attempt Authentication when Content-Type is json
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE)
|| request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
try {
BufferedReader br = request.getReader();
String str;
StringBuilder jsonStr = new StringBuilder();
while ((str = br.readLine()) != null) {
jsonStr.append(str);
}

log.info("本次登录请求参数:{}", jsonStr);

JSONObject jsonObject = JSON.parseObject(jsonStr.toString());

UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
jsonObject.getString("username"), jsonObject.getString("password"));
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
log.info("用户登录, 请求参数 不正确");
throw new AuthenticationServiceException("获取报文请求参数失败");
} catch (JSONException e) {
log.info("用户登录, 请求报文格式 不正确");
throw new AuthenticationServiceException("请求报文, 转换Json失败");
}
} else {
log.error("用户登录, contentType 不正确");
throw new AuthenticationServiceException(
"请求 contentType 不正确, 请使用 application/json;charset=UTF-8 或者 application/json;");
}

}

}

用户认证模块

  • 根据获取到的username从数据库中查询到密码, 将用户名密码赋值给UserDetails对象, 返回其他的框架会进行校验
  • 这边使用中是使用的手机号+验证码登录, 所以 上面json解析的也是 phoneNo+verificationCode
  • 在这块 username仅仅代指登录名, 可以是手机号可以是别的.
  • 这边使用中验证码是从redis中获取的. 获取不到返回失败, 获取到和传递的不一致也算失败.
package com.liuzhihang.demo.service;

import com.liuzhihang.demo.bean.UserDetailsImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

/**
* @author liuzhihang
*/
@Slf4j
@Component("userDetailServiceImpl")
public class UserDetailServiceImpl implements UserDetailsService {


/**
* 用来验证登录名是否有权限进行登录
*
* 可以通过数据库进行校验 也可以通过redis 等等
*
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {


UserDetailsImpl userDetailsImpl = new UserDetailsImpl();
userDetailsImpl.setUsername("liuzhihang");
userDetailsImpl.setPassword(new BCryptPasswordEncoder().encode("123456789"));
return userDetailsImpl;
}

}

请求校验过程

graph TD;
    A(请求接口) --> B(JwtPerTokenFilter#doFilterInternal 验证Header中的token);
    B --> C(DynamicAuthorityService#hasPermission 验证有没有请求url权限);
    C --> D(处理逻辑);
    D --> E(返回结果)

JWTToken拦截器

主要是拦截请求, 验证Header中的token是否正确

package com.liuzhihang.demo.filter;

import com.liuzhihang.demo.utils.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
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;

/**
* @author liuzhihang
* @date 2019-06-05 09:09
*/
@Slf4j
@Component
public class JwtPerTokenFilter extends OncePerRequestFilter {

@Autowired
private JwtTokenUtil jwtTokenUtil;

/**
* 存放Token的Header Key
*/
private static final String HEADER_STRING = "token";

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

String token = request.getHeader(HEADER_STRING);
if (null != token && !jwtTokenUtil.isTokenExpired(token)) {
UserDetails userDetails = jwtTokenUtil.getUserDetailsFromToken(token);
String username = userDetails.getUsername();

if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

// 通过 username 查询数据库 获取token 然后和库中token作比较

if (username.equals("liuzhihang")) {

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
filterChain.doFilter(request, response);
}

}

URI动态校验

package com.liuzhihang.demo.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.util.HashSet;
import java.util.Set;

/**
* 动态权限认证
*
* @author liuzhihang
* @date 2019-06-25 15:51
*/
@Slf4j
@Component(value = "dynamicAuthorityService")
public class DynamicAuthorityService {


public boolean hasPermission(HttpServletRequest request, Authentication authentication) {

try {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails && authentication instanceof UsernamePasswordAuthenticationToken) {
// 本次请求的uri
String uri = request.getRequestURI();

// 获取当前用户
UserDetails userDetails = (UserDetails) principal;

String username = userDetails.getUsername();
log.info("本次用户请求认证, username:{}, uri:{}", username, uri);

// 从数据库取逻辑
if (username.equals("liuzhihang")){
Set<String> set = new HashSet<>();
set.add("/homeInfo");
set.add("/getAllUser");
set.add("/editUserInfo");
if (set.contains(uri)) {
return true;
}
}

}
} catch (Exception e) {
log.error("用户请求登录, uri:{} error", request.getRequestURI(), e);
return false;
}
return false;
}
}

测试

脚本在 httpclient脚本

POST localhost:8080/login
Content-Type: application/json

{
"username": "liuzhihang",
"password": "123456789"
}
### 请求接口脚本

POST localhost:8080/homeInfo
Content-Type: application/json
token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY5MDI1NjY4LCJleHAiOjE1Njk2MzA0Njh9.Kot_uLnwtcq-t5o4x3V-xBnpf-mKEi7OV2eAfgMCKLk
###

返回:

{
"resultCode": "0000",
"resultMsg": "登录成功",
"resultTime": "20190920191038",
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY4OTc3ODM4LCJleHAiOjE1Njk1ODI2Mzh9.MAS9VkFdCF3agkCgTtc0VzPMFjY42vFyIvAEzkSeAfs"
}

参考

前后端分离 SpringBoot + SpringSecurity + JWT + RBAC 实现用户无状态请求验证

文章作者: liuzhihang
文章链接: https://liuzhihang.com/2019/07/22/springsecurity-jwt-springboot-project.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Notes

评论