参照上次的例子:Spring Security简单应用https://blog.csdn.net/xxkalychen/article/details/102498016
这个例子的应用场景是在调用接口的时候发现不能通过验证就自动跳转到登录页面登录,但是登陆成功之后,身份验证信息只是倚靠session来验证。在分布式服务中,我们每一次调用接口都会做验证,这就需要我们在登录时要获取一个jwt,每次调用业务接口的时候都要带着这个jwt的。于是,我们需要把Spring Security和JWT结合起来运用。
一、我们在上次构建的Security项目的基础之上来扩展,我们把端口修改为6007
二、添加JWT的相关pom依赖
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- GSON -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>
三、先领域建模。我们需要一个记录用户信息的模型User.java,为了简单一点,我们把用户数据和用户登录参数的模型设计为一致,实际上应该是不一致的。
package com.chris.sec.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* create by: Chris Chan
* create on: 2019/9/26 8:09
* use for:
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String username;
private String password;
}
把两个常量收集起来
AppConstans.java
/**
* create by: Chris Chan
* create on: 2019/9/26 8:08
* use for: 全局常量
*/
public interface AppConstants {
//JWT默认指纹
String JWT_SECRET_KEY_NORMAL="JKHDWNCJKLFKKJHDKEKLLDKJKLFHJKHSGHAJKFJLNKSFLLKDLKL";
//响应头Authorization
String KEY_AUTHORIZATION="Authorization";
}
四、由于要用到JWT,我们用一个JWTUtils来构建和解析JWT
package com.chris.sec.utils;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.model.UserInfo;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
/**
* create by: Chris Chan
* create on: 2019/9/27 13:02
* use for: JWT处理工具
*/
public class JWTUtils {
/**
* 通过User对象构建jwt
* 此处是专用的
*
* @param userInfo
* @return
*/
public static String createJWTByLoginUser(UserInfo userInfo, String[] authorities) {
//构建有效载荷有关用户信息的部分
Map<String, Object> claimsMap = new HashMap<>(16);
//目前把用户名存进去
claimsMap.put("username", userInfo.getUsername());
claimsMap.put("authorities", authorities);
//构建JWT
String token = Jwts.builder()
.setId(UUID.randomUUID().toString())
.setClaims(claimsMap)
.setSubject(userInfo.getUsername())
.setExpiration(Date.from(LocalDateTime.now().plusDays(1).toInstant(ZoneOffset.of("+8"))))
.signWith(SignatureAlgorithm.HS512, AppConstants.JWT_SECRET_KEY_NORMAL)
.compact();
return token;
}
/**
* 解析token
*
* @param token
* @return
*/
public static Claims parseTokenForBearer(String token) {
return Jwts.parser()
.setSigningKey(AppConstants.JWT_SECRET_KEY_NORMAL)
.parseClaimsJws(token.replace("Bearer ", ""))
.getBody();
}
/**
* 从token中解析出username
*
* @param token
* @return
*/
public static String getUsernameFromToken(String token) {
Object usernameObj = parseTokenForBearer(token).get("username");
return String.valueOf(usernameObj);
}
/**
* 获取权限列表
*
* @param token
* @return
*/
public static String[] getAuthorityFromToken(String token) {
Object obj = parseTokenForBearer(token).get("authorities");
ArrayList<String> authoritiyList = (ArrayList<String>) obj;
String[] authorities = new String[authoritiyList.size()];
return authoritiyList.toArray(authorities);
}
}
五、这一次不用默认的登录处理,而是使用我们自己编写的处理逻辑,我们需要写两个过滤器,用来处理登录成功构建头部Authorization和调用接口时验证JWT。
package com.chris.sec.config.filter;
import com.chris.sec.model.UserInfo;
import com.chris.sec.service.UserService;
import com.chris.sec.utils.JWTUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
/**
* create by: Chris Chan
* create on: 2019/9/26 11:43
* use for: 自动登录处理 接收并解析用户信息
*/
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JWTLoginFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
//从权限认证信息中心读取用户名和密码
UserInfo userInfo = new ObjectMapper().readerFor(UserInfo.class).readValue(request.getInputStream());
//创建身份验证token
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userInfo.getUsername(), userInfo.getPassword(), Collections.emptyList());
//创建用户身份验证信息
Authentication authenticate = authenticationManager.authenticate(token);
return authenticate;
} catch (IOException e) {
//e.printStackTrace();
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//super.successfulAuthentication(request, response, chain, authResult);
//如果验证通过就创建token返回
//获取用户信息
User user = (User) authResult.getPrincipal();
//构建jwt
String username = user.getUsername();
String token = JWTUtils.createJWTByLoginUser(new UserInfo(username, user.getPassword()), UserService.getAuthorities(username));
//把JWT添加到响应头
response.addHeader("Authorization", "Bearer " + token);
}
}
package com.chris.sec.config.filter;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.utils.JWTUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
/**
* create by: Chris Chan
* create on: 2019/9/27 10:38
* use for:
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//获取头部Authorization信息
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
//如果没找到湖综合不是Bearer开头就放到下一个过滤器处理,比如登录接口就不带
if (StringUtils.isEmpty(token) || !token.startsWith("Bearer")) {
chain.doFilter(request, response);
return;
}
//创建验证信息
UsernamePasswordAuthenticationToken authenlication = createAuthenlication(request);
//放入上下文进行验证
SecurityContextHolder.getContext().setAuthentication(authenlication);
chain.doFilter(request, response);
}
/**
* 创建身份验证信息
*
* @param request
* @return
*/
private UsernamePasswordAuthenticationToken createAuthenlication(HttpServletRequest request) {
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(token)) {
return null;
}
String username = String.valueOf(JWTUtils.getUsernameFromToken(token));
if (!StringUtils.isEmpty(username)) {
return new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
}
return null;
}
}
六、SecurityConfig需要修改,把两个过滤器加进去,同时去掉登录设置,应为登录已经被接口替代了,验证过滤器也做了处理。
package com.chris.sec.config;
import com.chris.sec.config.filter.JWTAuthenticationFilter;
import com.chris.sec.config.filter.JWTLoginFilter;
import com.chris.sec.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.crypto.password.PasswordEncoder;
/**
* create by: Chris Chan
* create on: 2019/10/11 11:48
* use for:
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserService userService;
@Autowired
PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.authorizeRequests()
.antMatchers("/api/user/login").permitAll()
.antMatchers("/api/user/loginBasic").permitAll()
.anyRequest().authenticated()
.and()
.addFilter(new JWTLoginFilter(authenticationManager()))
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userService)
.passwordEncoder(passwordEncoder);
}
}
七、UserService需要对接口业务逻辑做处理,所以也需要做一些增加和修改。
package com.chris.sec.service;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.model.UserInfo;
import com.chris.sec.utils.JWTUtils;
import io.jsonwebtoken.impl.TextCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
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.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* create by: Chris Chan
* create on: 2019/10/11 12:31
* use for:
*/
@Service
public class UserService implements UserDetailsService {
private static Map<String, String> userMap = new HashMap<>(16);
private static Map<String, String> userAuthMap = new HashMap<>(16);
@Autowired
PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isEmpty(username)) {
return null;
}
UserInfo userInfo = findUser(username);
if (null == userInfo) {
return null;
}
return new User(username, userInfo.getPassword(), getAuthorityList(username));
}
/**
* 返回密码
* 这个方法可以假设是从数据库dao层获取到用户信息
*
* @param username
* @return
*/
private UserInfo findUser(String username) {
if (null == userMap) {
userMap = new HashMap<>(16);
}
//内置几个用户
if (userMap.size() == 0) {
userMap.put("zhangsanfeng", passwordEncoder.encode("123123"));
userMap.put("lisifu", passwordEncoder.encode("123123"));
userMap.put("songzihao", passwordEncoder.encode("123123"));
}
String password = userMap.get(username);
if (StringUtils.isEmpty(password)) {
return null;
}
return new UserInfo(username, password);
}
/**
* 获取用户权限
* 这个方法也可以在数据库中查询
*
* @param username
* @return
*/
public static String[] getAuthorities(String username) {
if (null == userAuthMap) {
userAuthMap = new HashMap<>(16);
}
//内置几个用户权限
if (userAuthMap.size() == 0) {
userAuthMap.put("zhangsanfeng", "ROLE_ADMIN,ROLE_USER");
userAuthMap.put("lisifu", "ROLE_ADMIN,ROLE_USER");
userAuthMap.put("songzihao", "ROLE_SYS,ROLE_ADMIN,ROLE_USER");
}
return userAuthMap.get(username).split(",");
}
/**
* 获取用户权限集合
*
* @param username
* @return
*/
public static List<GrantedAuthority> getAuthorityList(String username) {
return AuthorityUtils.createAuthorityList(getAuthorities(username));
}
/**
* 自定义Basic方式登录
* 涉及上次面试题
*
* @param request
* @param response
* @return
*/
public String loginBasic(HttpServletRequest request, HttpServletResponse response) throws IOException {
//从头部取得数据
String authToken = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(authToken) || !authToken.startsWith("Basic ")) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
//解析
String loginInfo = TextCodec.BASE64URL.decodeToString(authToken.replace("Basic ", ""));
String[] split = loginInfo.split(":");
if (split.length != 2) {
throw new RuntimeException("token格式不正确");
}
String username = split[0];
String password = split[1];
return login(new UserInfo(username, password), response);
}
/**
* 自定义登录
*
* @param loginUser
* @param response
* @return
*/
public String login(UserInfo loginUser, HttpServletResponse response) throws IOException {
String username = loginUser.getUsername();
UserInfo userInfo = findUser(username);
//测试 上一次面试相关 如果没找到用户 抛401
if (null == userInfo) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return null;
}
//如果密码不匹配 抛403
if (!passwordEncoder.matches(loginUser.getPassword(), userInfo.getPassword())) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return null;
}
return JWTUtils.createJWTByLoginUser(loginUser, getAuthorities(username));
}
}
八、为了比较接近真实业务,登录接口我们需要自己来写,所以我们新建一个UserApi.java来处理用户登录
package com.chris.sec.api;
import com.chris.sec.model.UserInfo;
import com.chris.sec.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* create by: Chris Chan
* create on: 2019/9/26 7:59
* use for:
*/
@RestController
@RequestMapping("/api/user")
public class UserApi {
@Autowired
UserService userService;
@PostMapping("/login")
public String login(@RequestBody UserInfo userInfo, HttpServletResponse response) throws IOException {
String token = userService.login(userInfo, response);
return token;
}
/**
* basic登录
* 涉及上次的面试题
* 登录信息不在参数中,而在头部信息的Authrication中,以Basic 开头,用户名和密码用冒号连接,并做过Base64编码
*
* @return
*/
@PostMapping("/loginBasic")
public String loginBasic(HttpServletRequest request, HttpServletResponse response) throws IOException {
return userService.loginBasic(request, response);
}
}
注明一下,loginBasic是有一次面试的一个机试题,当时理解错了,花了很多时间也没弄出效果。后来自己有设计了一下,感觉并不难。于是在这里测试一下,不影响本次的主题测试。
九、测试一下。由于我们需要post请求,所以我们在PostMan中来进行测试。
1. 测试登录
我们获得了正确的JWT.
2. 测试接口
我们需要在Auth中天上我们刚才获取的token,可以看到能够正确请求接口。如果填错了会报错。
说明一下,这个token如果不填就不会验证,因为过滤器没有处理。建议登录的时候适用basic验证,然后对不带token的进行限制,也可以根据url进行区别限制。在此不多做逻辑。
我们还可以用这种方式来调用接口:
一般前端调用的主要手段,就是这样构建头信息。
十、由于在权限验证过滤器中构建用户信息是使用了
return new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
这一行没有带权限信息,导致具有权限约束的接口都不能调用。我们把把上句改成:
return new UsernamePasswordAuthenticationToken(username, null, AuthorityUtils.createAuthorityList(JWTUtils.getAuthorityFromToken(token)));
你就会发现,用zhangsanfeng和lisifu这两个账号都不能访问test2这个接口,而用songzihao这个用户就可以。
十一、最后测试一下我的loginBasic接口
调用时成功的。可是一般前端怎么构建呢?
我们需要把用户名和密码用username:password的结构进行base64url编码,然后放入头部信息。
我们通过一个网站来对用户名和密码进行编码http://tool.chinaz.com/Tools/Base64.aspx:
然后以请求头构建的方式来请求:
请求成功。用这个token请求test接口是没问题的,请求test2接口报403,因为没权限,换个用户songzihao试试就可以。
附:最后还是把权限验证的过滤器做了修改:
package com.chris.sec.config.filter;
import com.chris.sec.consts.AppConstants;
import com.chris.sec.service.UserService;
import com.chris.sec.utils.JWTUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* create by: Chris Chan
* create on: 2019/9/27 10:38
* use for:
*/
public class JWTAuthenticationFilter extends BasicAuthenticationFilter {
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//除非是登录,否则Authorization头信息必须要有,没有就抛异常
StringBuffer requestURL = request.getRequestURL();
if (requestURL.toString().contains("/api/user/login")) {
chain.doFilter(request, response);
return;
}
//创建验证信息
UsernamePasswordAuthenticationToken authenlication = createAuthenlication(request);
//放入上下文进行验证
SecurityContextHolder.getContext().setAuthentication(authenlication);
chain.doFilter(request, response);
}
/**
* 创建身份验证信息
*
* @param request
* @return
*/
private UsernamePasswordAuthenticationToken createAuthenlication(HttpServletRequest request) {
String token = request.getHeader(AppConstants.KEY_AUTHORIZATION);
if (StringUtils.isEmpty(token)) {
return null;
}
String username = String.valueOf(JWTUtils.getUsernameFromToken(token));
if (!StringUtils.isEmpty(username)) {
return new UsernamePasswordAuthenticationToken(username, null, UserService.getAuthorityList(username));
}
return null;
}
}
这样一来,根据url放过了登录接口,其他的接口一律要检查,没有token也不行,全部提示无权限。
实际业务中,登录获取JWT和调用接口验证JWT是分布不同的服务中的,两个过滤器的业务也就应该分开设置。不过有关JWT的相关信息应该统一。
后记:
最近研究表明,这篇文章中JWTLoginFilter其实并没有起到作用。UsernamePasswordAuthenticationFilter继承自AbstractAuthenticationProcessingFilter,适用于表单登录,AbstractAuthenticationProcessingFilter的构造方法中规定了表单登录的默认路径和method,method规定必须是post。这个路径新版本有所变化,是“/login”,不是这个路径就不过被这个过滤器处理。由于这个构造方法是protected类型,所以我们最合适的办法不是继承UsernamePasswordAuthenticationFilter,而是直接继承它的父类AbstractAuthenticationProcessingFilter,重写doFilter、attemptAuthentication、successfulAuthentication、unsuccessfulAuthentication。请求进来之后,会交给attemptAuthentication处理,构建了Token交给系统验证,验证通过后会调用successfulAuthentication,未通过会抛出异常,doFilter捕获了异常就会调用unsuccessfulAuthentication,用户可以在这两个方法中自定义成功与失败的返回,这里与接口调用没有关系。上次是我理解错误了。不过还要说明的是,上栗我自作聪明在attemptAuthentication方法的处理中加上了try-catch块,这真是给自己挖了一个大坑,反复调试总感觉很多地方没有调用,最后才发现该抛的异常没有抛出,都在这里被捕获却没有处理。坚决去掉这个块!