Appearance
::: note
JWT 配合 Spring Security 使用将会使你的项目更加安全,这也是主流。 :::
认证授权过程
(1)如果是基于 Session,那么 Spring Security 会对 cookie 里的 sessionid 进行解析,找到服务器存储的 session 信息,然后判断当前用户是否符合请求的要求。
(2)如果是 token,则是解析出 token,然后将当前请求加入到 Spring Security 管理的权限信息中
如果系统的模块众多,每个模块都需要进行授权与认证,所以我们选择基于 token 的形式 进行授权与认证,用户根据用户名密码认证成功,然后获取当前用户角色的一系列权限值,根据用户名相关信息生成 token 返回给浏览器,浏览器将 token 记录到 cookie 中,每次调用 api 接口都默认将 token 携带到 header 请求头中,Spring Security 解析 header 头获取 token 信息,解析 token 获取当前用户名,根据用户名就可以从 mysql 或者 redis 中获取权限列表,这样 Spring Security 就能够判断当前请求是否有权限访问。
JWT组成
JWT 的核心 token 俗称令牌。
令牌类型
先看一个 token 长什么样
java
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJrZWxlIiwiZXhwIjoxNjQwNDA0MzgzLCJjcmVhdGVkIjoxNjQwMzYxMTgzMjI0LCJhdXRob3JpdGllcyI6W3siYXV0aG9yaXR5IjoiUk9MRV9OT1JNQUwifSx7ImF1dGhvcml0eSI6IlJPTEVfQURNSU4ifV19.BL_XFikpxXxzzH6hI_3lJgB7IWezyM4m33IISV6O5USeq5z1xuWf8rB02S-Rg8tdmzNBFVHXvZO-zgdFYE8mIQ
可以看到 token 是一个很长的字符串,字符之间通过「 . 」分隔符分为三个子串,每一个子串表示了一个功能块,总共有以下三个部分:JWT 头、有效载荷和签名。
JWT头
JWT 头部分是一个描述 JWT 元数据的 JSON 对象,通常如下所示。
json
{
"alg": "HS256",
"typ": "JWT"
}
在上面的代码中,alg 属性表示签名使用的算法,默认为 HMAC SHA256(写为 HS256);typ 属性表示令牌的类型,JWT 令牌统一写为 JWT。最后,使用 Base64 URL 算法将上述 JSON 对象转换为字符串保存。
有效载荷
有效载荷部分,是 JWT 的主体内容部分,也是一个 JSON 对象,包含需要传递的数据。
JWT 指定七个默认字段供选择:
iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID 用于标识该 JWT 除以上默认字段外,我们还可以自定义私有字段,如下:
json{ "sub": "1234567890", "name": "Helen", "admin": true }
请注意,默认情况下 JWT 是未加密的,任何人都可以解读其内容,因此不要构建隐私信息字段,存放保密信息,以防止信息泄露。
JSON 对象也使用 Base64 URL 算法转换为字符串保存。
签名哈希
签名哈希部分是对上面两部分数据签名,通过指定的算法生成哈希,以确保数据不会被篡改。
首先,需要指定一个密码(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用标头中指定的签名算法(默认情况下为 HMAC SHA256)根据以下公式生成签名:
java
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(claims), secret)
在计算出签名哈希后,JWT 头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用「 . 」分隔,就构成整个 JWT 对象。
Base64URL算法
如前所述,JWT 头和有效载荷序列化的算法都用到了 Base64URL。该算法和常见 Base64 算法类似,稍有差别。
作为令牌的 JWT 可以放在 URL 中(例如 api.example/?token=xxx
)。 Base64 中用的三个字符是 "+","/" 和 "=",由于在 URL 中有特殊含义,因此 Base64URL 中对他们做了替换:"=" 去掉,"+" 用 "-" 替换,"/" 用 "_" 替换,这就是 Base64URL 算法。
实战
该实战不推荐看了,比较粗糙,几天后我已经写了新的实战,请看 Security - JWT登录实战,那个内容的一些工具类绝对是生产开发需要的,可以将其收藏。
并不是说本次实战不要看,本内容也有一些可取之处,如业务类和新实战的业务类不太一样,但是两者都是常用的,具体使用哪个,看你的需求。
目录结构如下:
md
包名 cn.kbt
├── bean (数据库对应的实体类包)
│ ├── User
│ ├── Role
│ ├── SecurityUser
├── config (Security 配置类)
│ ├── TokenWebSecurityConfig
├── controller
│ ├── JwtAuthController
├── mapper
│ ├── UserMapper
├── security (Security 实现类)
│ ├── TokenManager
│ ├── TokenLogoutHandler
│ ├── UnauthorizedEntryPoint
├── service
│ ├── impl
│ └── UserServiceImpl
│ ├── UserService
│
└── SpringSecurityApplication
实体类
和数据库对应的实体类 User,记得加上 set 和 get 方法
java
public class User {
private String username;
private String password;
private List<Role> roleList;
// set 和 get 方法
}
和数据库对应的权限类 Role,记得加上 set 和 get 方法
java
public class Role {
private Long id;
private String roleName;
// set 和 get 方法
}
和 Spring Security 对应的 SecurityUser 类,该类存储用户信息,然后放到 Spring Security 里
java
public class SecurityUser implements UserDetails {
// 当前登录用户
private transient User currentUserInfo;
// 当前权限列表
private List<String> permissionValueList;
public SecurityUser() {
}
public SecurityUser(User user) {
if (user != null) {
this.currentUserInfo = user;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) {
continue;
}
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return currentUserInfo.getPassword();
}
@Override
public String getUsername() {
return currentUserInfo.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
认证授权工具类
token 操作的工具类:TokenManager
,token 由 JWT 头(JWT 生成等)、有效负载(用户信息等)、签名(自定义字符串等)组成
java
@Component
public class TokenManager {
// 指定 token 生命时长
private long tokenLife = 24 * 60 * 60 * 1000;
// 指定编码和解码的字符串,即签名
private String tokenSignKey = "123456";
public String createToken(String username) {
String token = Jwts.builder().setSubject(username) // token 的主体部分:用户信息
.setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)) // token的有效时长:一天
.signWith(SignatureAlgorithm.HS512, tokenSignKey) // token 的签名
.compressWith(CompressionCodecs.GZIP)
.compact();
return token;
}
public String getUserFromToken(String token) {
String user = Jwts.parser()
.setSigningKey(tokenSignKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
return user;
}
public void removeToken(String token) {
// jwt 的 token 无需删除,客户端扔掉即可。
}
}
用户登出的工具类:TokenLogoutHandler
,退出登录后删除 Token,并且在 Redis 数据库删除相关信息
java
@Component
public class TokenLogoutHandler implements LogoutHandler {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenLogoutHandler(TokenManager tokenManager, RedisTemplate redisTemplate) {
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
String token = request.getHeader("token");
if (token != null) {
tokenManager.removeToken(token);
// 清空当前用户缓存中的权限数据
String userName = tokenManager.getUserFromToken(token);
redisTemplate.delete(userName);
}
ResponseUtil.out(response, R.ok());
}
}
未授权统一处理:UnauthorizedEntryPoint
java
@Component
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
认证授权拦截器
认证的拦截器:TokenLoginFilter
。用户登录成功,生成 Token,存入数据库,登录失败返回失败结果
java
@Component
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenLoginFilter(AuthenticationManager authenticationManager,
TokenManager tokenManager, RedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
this.setPostOnly(false);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
}
/**
* 用户登录时,处理请求
* 1. 获取用户操作的表单信息
* 2. 进行存入权限封装的类里
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest req,
HttpServletResponse res) throws AuthenticationException {
try {
User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 获取权限封装的类
* 登录成功则生成 token,并把相关信息存入数据库
* 相关信息指的是,用户名,密码,权限信息
*/
@Override
protected void successfulAuthentication(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain,
Authentication auth) throws IOException, ServletException {
SecurityUser user = (SecurityUser) auth.getPrincipal();
String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList());
ResponseUtil.out(res, R.ok().data("token", token));
}
/**
* 登录失败
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException {
ResponseUtil.out(response, R.error());
}
}
授权拦截器:TokenAuthenticationFilter
。登录成功后,进行权限认证(认证成功将用户信息存入上下文),源码已经实现,不需要自己手动配置
java
@Component
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthenticationFilter(AuthenticationManager authManager,
TokenManager tokenManager,
RedisTemplate redisTemplate) {
super(authManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
/**
* 将权限存入上下文中
*/
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
if(req.getRequestURI().indexOf("admin") == -1) {
chain.doFilter(req, res);
return;
}
UsernamePasswordAuthenticationToken authentication = authentication = getAuthentication(req);
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
ResponseUtil.out(res, R.error());
}
// 放行资源
chain.doFilter(req, res);
}
/**
* 通过请求获取请求头的 token
*/
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token 置于 header 里
String token = request.getHeader("token");
if (token != null && !"".equals(token.trim())) {
String userName = tokenManager.getUserFromToken(token);
List<String> permissionValueList = (List<String>) redisTemplate.opsForValue().get(userName);
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
if (!StringUtils.isEmpty(userName)) {
return new UsernamePasswordAuthenticationToken(userName, token, authorities);
}
return null;
}
return null;
}
}
业务类
其他的类我就不写了,就是 service 层、mapper 层,至于数据库的三个张表(用户表、角色表、用户角色关联表),效果如图:
提供 findUserByUsername
方法的 sql:
sql
select r.* from sys_user u,sys_role r,user_role ur where u.id = ur.user_id and r.id = ur.role_id and username = #{username}
最重要的是这个业务类,自定义类继承 UserDetailsService,实现 Spring Security 的业务,重写方法。该类将由 Spring Security 自动调用。
认证授权拦截器 TokenLoginFilter
的 26 行代码 authenticationManager.authenticate
的内部就会触发下面实现的 loadUserByUsername
方法,进行数据库的交互,用户名和密码判断是否正确
java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("该用户不存在");
}
// 创建 GrantedAuthority 的 List 集合,用于存储 GrantedAuthority
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
// 创建单个 GrantedAuthority,用户存储用户信息
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(userInfo.getRoleList());
// 存储进去
grantedAuthorities.add(grantedAuthority);
return new JwtUserDetails(user.getUsername(), user.getPassword(), grantedAuthorities);
}
}
配置类
前面内容写完,不放到核心配置类里,等于白写,核心配置类内容如下:
java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private TokenManager tokenManager;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private BCryptPasswordEncoder bcryptPasswordEncoder;
@Autowired
@Qualifier("userDetailsServiceImpl")
private UserDetailsService userDetailsService;
/**
* 核心配置
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnAuthEntryPoint()) // 没有权限访问的处理
.and().authorizeRequests() // 开启认证
.anyRequest().authenticated()// 任何请求需要认证
.and().logout().logoutUrl("/logout") // 退出操作
.addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate)) // 退出后token的操作
// 权限认证处理类
.and().addFilter(new TokenAuthFilter(authenticationManager(),tokenManager,redisTemplate))
// 添加自定义的过滤器,即用户登录后 token 的创建
.addFilter(new TokenLoginFilter(authenticationManager(),tokenManager,redisTemplate)).httpBasic()
.and().csrf().disable();
}
/**
* 调用 userDetailsService 和密码处理
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bcryptPasswordEncoder);
}
/**
* 不进行认证的路径设置,可以直接访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/api/**", "/other/**");
}
}
测试类
编写一个 Controller,用于登录
java
@RestController
public class JwtAuthController {
@Autowired
private UserService userService;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private TokenManager tokenManager;
@PostMapping("/login")
public String login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletRequest request) {
User user = userService.findByUsername(username);
// 账号不存在
if (user == null) {
return HttpResult.error("账号不存在");
// 数据库的密码是加密过的
}else if(!bCryptPasswordEncoder.matches(password,user.getPassword())) {
return HttpResult.error("密码不正确");
}
// 全部正确,则生成 token,返回给客户端
String token = tokenManager.createToken(username);
return token;
}
}