背景
前段时间做了一个项目, 因为涉及到权限认证, 所以分别调研了 SpringSecurity 和 Apache Shiro. 最后选择使用了 SpringSecurity + JWT做权限认证, 现在项目已经结束, 总相关笔记. 项目下载地址 jwt-demo
- 使用JWT生成token
- token存储在数据库中
- 使用 application/json 登录
- 使用手机号进行登录
-
配置过程
添加依赖
分别添加 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;
}
}
登录校验过程
{% mermaid %}
graph TD;
A(请求登录) —> B(CustomizeAuthenticationFilter#attemptAuthentication 解析请求的json);
B —> C(UserDetailServiceImpl#loadUserByUsername 验证用户名密码);
C —> D(AuthenticationSuccessHandlerImpl#onAuthenticationSuccess 构建返回参数 包括token);
D —> E(返回结果)
{% endmermaid %}自定义拦截器解析 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;
}
}
请求校验过程
{% mermaid %}
graph TD;
A(请求接口) —> B(JwtPerTokenFilter#doFilterInternal 验证Header中的token);
B —> C(DynamicAuthorityService#hasPermission 验证有没有请求url权限);
C —> D(处理逻辑);
D —> E(返回结果)
{% endmermaid %}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 实现用户无状态请求验证