背景
前段时间做了一个项目, 因为涉及到权限认证, 所以分别调研了 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
请求返回基类
@Datapublic class BaseReqDto implements Serializable {private String version;}@Datapublic class BaseRespDto implements Serializable {private String resultCode;private String resultMsg;private String resultTime;}
登录请求返回对象
@Datapublic class LoginReqDto {private String username;private String token;}@Datapublic 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;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return this.authorities;}public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {this.authorities = authorities;}@Overridepublic String getPassword() {return this.password;}@Overridepublic String getUsername() {return this.username;}public void setUsername(String username) {this.username = username;}public void setPassword(String password) {this.password = password;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}}
用户未登录handle
/*** 用户登录认证, 未登录返回信息** @author liuzhihang* @date 2019-06-04 13:52*/@Componentpublic class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");@Overridepublic 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*/@Componentpublic class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");@Overridepublic 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*/@Componentpublic class AccessDeniedHandlerImpl implements AccessDeniedHandler {private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");@Overridepublic 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@Componentpublic class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");@Resourceprivate JwtTokenUtil jwtTokenUtil;@Overridepublic 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*/@Componentpublic 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*/@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate UserDetailServiceImpl userDetailServiceImpl;@Resourceprivate 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*/@Autowiredpublic void configureUserInfo(AuthenticationManagerBuilder auth) throws Exception {// 放入自己的认证授权用户, 内部逻辑需要自己实现// UserDetailServiceImpl implements UserDetailsServiceauth.userDetailsService(userDetailServiceImpl);}@Overrideprotected 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();}/*** 密码加密器*/@Beanpublic PasswordEncoder passwordEncoder() {/*** BCryptPasswordEncoder:相同的密码明文每次生成的密文都不同,安全性更高*/return new BCryptPasswordEncoder();}@BeanCustomizeAuthenticationFilter 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*/@Slf4jpublic class CustomizeAuthenticationFilter extends UsernamePasswordAuthenticationFilter {@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)throws AuthenticationException {// attempt Authentication when Content-Type is jsonif (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*/@Overridepublic 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@Componentpublic class JwtPerTokenFilter extends OncePerRequestFilter {@Autowiredprivate JwtTokenUtil jwtTokenUtil;/*** 存放Token的Header Key*/private static final String HEADER_STRING = "token";@Overrideprotected 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) {// 本次请求的uriString 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/loginContent-Type: application/json{"username": "liuzhihang","password": "123456789"}### 请求接口脚本POST localhost:8080/homeInfoContent-Type: application/jsontoken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY5MDI1NjY4LCJleHAiOjE1Njk2MzA0Njh9.Kot_uLnwtcq-t5o4x3V-xBnpf-mKEi7OV2eAfgMCKLk###
{"resultCode": "0000","resultMsg": "登录成功","resultTime": "20190920191038","token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsaXV6aGloYW5nIiwiaWF0IjoxNTY4OTc3ODM4LCJleHAiOjE1Njk1ODI2Mzh9.MAS9VkFdCF3agkCgTtc0VzPMFjY42vFyIvAEzkSeAfs"}
参考
前后端分离 SpringBoot + SpringSecurity + JWT + RBAC 实现用户无状态请求验证
