Spring Security 是 Spring 社区的一个顶级项目,也是 Spring Boot 官方推荐使用的安全框架。Spring Security和Shiro也是当前广大应用使用比较广泛的两个安全框架。
Spring Security 应用级别的安全主要包含两个主要部分,即登录认证(Authentication)和访问授权(Authorization),首先用户登录的时候传入登录信息,登录验证器完成登录认证并将登录认证好的信息存储到请求上下文,然后再进行其他操作,如在进行接口访问、方法调用时,权限认证器从上下文中获取登录认证信息,然后根据认证信息获取权限信息,通过权限信息和特定的授权策略决定是否授权。
在IDEA中创建Springboot项目,引入以下内容
其中Springboot DevTools、Lombok不是必须件
项目搭建好后,引入JWT依赖。为了方便调试,本例中还将引入Swagger项目。由于引入了Swagger,所以在之后的权限拦截中要额外配置Swagger资源跳过验证,具体后文会说。
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
我并没有使用Swagger-ui,而是使用了Layui来作为Swagger的展示,所以没有引用到swagger-ui的依赖。swagger-ui-layer只支持RestController的接口,请根据自己项目实际情况选择Swagger的UI框架。
<dependency>
<groupId>io.springfoxgroupId>
<artifactId>springfox-swagger2artifactId>
<version>2.6.1version>
dependency>
<dependency>
<groupId>com.github.ohcomeyesgroupId>
<artifactId>swagger-ui-layerartifactId>
<version>1.2version>
dependency>
添加一个Swagger配置类,除了常规配置外,还需要增加一个令牌属性,使其可以在接口调用的时候传递令牌。
package com.vansen.zuanyunfei_backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.util.ArrayList;
import java.util.List;
/**
* Swagger config class
*
* @author Nicemorning
* @date Create in 1:58 2020/7/24 0024
*/
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi() {
// Construct request parameters,put token into request header
ParameterBuilder parameterBuilder = new ParameterBuilder();
List<Parameter> parameters = new ArrayList<>();
parameterBuilder.name("Authorization")
.description("AuthorizationToken")
.modelRef(new ModelRef("string"))
.parameterType("header")
.required(false)
.build();
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build()
.globalOperationParameters(parameters);
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("title")
.description("description")
.version("1.0")
.contact(new Contact("name", "url", "email"))
.build();
}
}
启动项目后,在浏览器中打开http://localhost:8080/api-docs.html
即可查看Swagger的UI展示。
由于引入了SpringSecurity,但是没有对其进行配置。所以不出意外的话会进入SpringSecurity默认的登录页面。
编写一个自定义的SpringSecurity配置了,继承WebSecurityConfigurerAdapter
抽象类,该抽象类定义了一些默认的configure()
方法,我们需要重写这些方法来实现自定义的安全逻辑。
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}
}
其中常用的是configure(AuthenticationManagerBuilder auth)
和configure(HttpSecurity http)
,可以简单的理解为其分别针对的逻辑为认证
和授权
。其中configure(WebSecurity web)
主要用于对web请求做一些过滤操作。
通常而言,静态资源、登录注册以及Swagger-ui是不需要鉴权的,可以让任何用户随意访问,我们只需要在configure(WebSecurity web)
方法中添加ignore()
配置即可。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
// static resources
.antMatchers("/favicon.ico")
.antMatchers("/hello")
.antMatchers("/static/**")
.antMatchers("**.js", "**.html", "**.css")
// swagger
.antMatchers("/v2/api-docs",
"/configuration/ui",
"/swagger-resources",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
"/swagger-resources/configuration/ui",
"/swagger-ui.html").
antMatchers("/api-docs.html");
}
也可以直接在configure(HttpSecurity http)
中使用permitAll()
来实现。
antMatchers(HttpMethod.POST, "/**").permitAll()
直接上代码。
@Override
protected void configure(HttpSecurity http) throws Exception {
// 由于使用的是JWT,所以需要禁用掉CSRF
http.cors().and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.addFilter(new AuthenticationTokenFilter(authenticationManager()));
http.headers().cacheControl();
}
其中cors()
和csrf()
是CSRF(跨站请求伪造)和CORS(跨域资源共享)的意思,将其禁用掉可以开启跨域功能。sessionManagement
是配置SpringSecurity的Session策略,由于使用无状态的JWT来做Token,所以这里将Session创建策略通过sessionCreationPolicy
方法定义为STATELESS
,即Spring Security不创建和使用Session。
关于Session创建策略的选项区别,可以直接查看源码注释。
/**
* Specifies the various session creation policies for Spring Security.
*
* @author Luke Taylor
* @since 3.1
*/
public enum SessionCreationPolicy {
/** Always create an {@link HttpSession} */
ALWAYS,
/**
* Spring Security will never create an {@link HttpSession}, but will use the
* {@link HttpSession} if it already exists
*/
NEVER,
/** Spring Security will only create an {@link HttpSession} if required */
IF_REQUIRED,
/**
* Spring Security will never create an {@link HttpSession} and it will never use it
* to obtain the {@link SecurityContext}
*/
STATELESS
}
回到刚才的配置中,authenticationEntryPoint
配置的是一个自定义的CusAuthenticationEntryPoint
处理类,还有一个是accessDeniedHandler
。他们的作用分别为:
如果需要增加AccessDeineHandler的话,只需要将上面的语句改成:
// .httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
// 注释掉原来的EntryPoint配置,使用下面的语句进行配置
.exceptionHandling().authenticationEntryPoint(new CusAuthenticationEntryPoint()).accessDeniedHandler(new CusAccessDeiedHandler())
往下继续读配置,会出现authorizeRequests
方法,这个方法需要和anyRequest
或antMatchers
配合使用,是用于指定路径是否需要鉴权的设置。鉴权的设置常用的有以下几种方法:
permitAll()
允许无条件访问anonymous()
允许匿名访问hasRole()
只允许有特定权限的用户访问authenticated()
需要进行认证最后一句addFilter
方法可以添加自定义的认证过滤器,上方配置的authenticated
会流转到这里所配置的过滤器中进行认证。
至此,需要留意几个关键点:
暂时先把上方的配置注释掉,重启一下项目,再次进入http://localhost:8080/api-docs.html
。此时可以看到已经能够正常进入页面,而不会被SpringSecurity所拦截了。
上文有提到,异常处理类有两个,分别是:
接下来我们将对以上两个类进行自定义业务逻辑
自定义一个CusAuthenticationEntryPoint
类,需要实现AuthenticationEntryPoint
接口,并添加@Component
注解,将该类交给Spring进行管理。
@Component
@Slf4j
public class CusAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
private ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException {
log.error("未授权访问 路径:{} - {}", request.getRequestURI(), e.getMessage());
ResMsg<Object> forbidden = ResMsg.errorMsg(request.getHeader("Authorization") == null ?
AuthExceptionGroup.FORBIDEN.getMessage() :
AuthExceptionGroup.BEARER_TOKEN_ERROR.getMessage());
String s = objectMapper.writeValueAsString(forbidden);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
response.getWriter().write(s);
response.flushBuffer();
}
}
这是我的配置示例,请根据项目实际需求自行改动。
自定义一个CusAccessDeniedHandler
类,需要实现AccessDeniedHandler
接口,该类同样需要交给Spring进行管理。
@Component
public class CusAccessDeniedHandler implements AccessDeniedHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
if (!response.isCommitted()) {
ResMsg<Object> forbidden = ResMsg.errorMsg(AuthExceptionGroup.FORBIDEN.getMessage());
String s = objectMapper.writeValueAsString(forbidden);
response.getWriter().write(s);
response.flushBuffer();
}
}
}
编写完成后,将这两个自定义的异常处理类添加到配置中,此时配置内容为:
http.cors().and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// .httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
.exceptionHandling()
.authenticationEntryPoint(new CusAuthenticationEntryPoint())
.accessDeniedHandler(new CusAccessDeniedHandler())
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.addFilter(new AuthenticationTokenFilter(authenticationManager()));
http.headers().cacheControl();
编写一个AuthenticationTokenFilter
类,需要继承BasicAuthenticationFilter
,该类不需要交给Spring管理。
BasicAuthenticationFilter
中定义了默认的doFilterInternal
过滤逻辑,我们可以重写它来实现自定义的业务逻辑。
public class AuthenticationTokenFilter extends BasicAuthenticationFilter {
public AuthenticationTokenFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String tokenHeader = request.getHeader(ConstantParams.TOKEN_HEADER);
// 如果请求头中没有Authorization信息则直接放行了
if (tokenHeader == null || !tokenHeader.startsWith(ConstantParams.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
// 如果请求头中有token,则进行解析,并且设置认证信息
SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
super.doFilterInternal(request, response, chain);
}
private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
String token = tokenHeader.replace(ConstantParams.TOKEN_PREFIX, "").trim();
String username = JwtUtil.getUsername(token);
List<String> roles = JwtUtil.getUserRole(token);
Collection<GrantedAuthority> authorities = new HashSet<>();
if (username == null) {
return null;
}
if (roles != null) {
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
}
return new UsernamePasswordAuthenticationToken(
CurrentUser.ofId(1)
.setUsername(ConstantParams.USERNAME)
.setPassword(ConstantParams.PASSWORD)
.setAuthorities(authorities),
null,
authorities);
}
}
其中UsernamePasswordAuthenticationToken
是SpringSecurity默认的用户信息实体逻辑,整个自定义的认证逻辑可以根据自身业务需要进行改动。核心的流程其实就是doFilterInternal
方法中所定义的内容。
本示例中,用户的请求如果需要进行认证的,将会进入到doFilterInternal
方法中,在方法中取得请求的头部信息,如果没有Token的,直接放行。如果有头部的,则调用getAuthentication
方法进行Token信息解析,用以取得当前访问的用户身份和权限范围。
在doFilterInternal
方法中,对于没有Token的将直接return,这一步的return会直接结束当前方法,继续执行调用者的逻辑。我们可以通过断点逐步调试的方式跟踪步骤,但是这里直接给出结论,return后会执行到刚才所自定义的CusAuthenticationEntryPoint
中,也就是说其出发了匿名用户访问无权限资源时的异常。
上面的代码中,有一段
CurrentUser.ofId(1)
.setUsername(ConstantParams.USERNAME)
.setPassword(ConstantParams.PASSWORD)
.setAuthorities(authorities)
用户的权限信息正常来说是通过数据库获得的,但是在此示例项目中,我直接将用户名和密码写在了ConstantParams类中,只用于做示范。
上方代码中涉及到了JwtUtil
工具类,JWT的工具类百度上有一大堆。在本文的最后也会附上我所使用的JWT工具类源码,这里暂时跳过不讨论。
至此我们已经完成了拦截配置
、认证逻辑自定义
、异常处理自定义
。还缺最后一步,即授权
。
在SpringSecurity配置类中的configure(AuthenticationManagerBuilder auth)
方法内,添加自定义的授权业务类。
@Autowired
private CusUserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
SpringSecurity默认使用的是UserDetailsService
授权业务类,如果没有什么特别高级的定制需求,只需要继承这个类重写一下接口即可。
自定义CusUserDetailsService
接口,继承UserDetailsService
public interface CusUserDetailsService extends UserDetailsService {
}
自定义CusUserDetailsServiceImpl
实现CusUserDetailsService
,实现loadUserByUsername
方法完成授权的业务自定义。通常情况下授权需要比对用户名密码等信息,是通过数据库获得的,在此我直接将用户名和密码写在了ConstantParams
类中。
@Service
@Slf4j
public class CusUserDetailsServiceImpl implements CusUserDetailsService
{
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
UserDetails userDetails = null;
try {
if (s.equals(ConstantParams.USERNAME)) {
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority("ALL"));
userDetails = new User(ConstantParams.USERNAME, ConstantParams.PASSWORD, authorities);
} else {
String message = "Username not corrected";
log.error(message);
throw new UsernameNotFoundException(message);
}
} catch (Exception e) {
log.error(e.getMessage());
}
return userDetails;
}
}
其中的UserDetails
是SpringSecurity自带的用户信息类,默认实现是User
,其中包含以下信息,如果没有什么特别要求的话,直接使用就足够了。
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
if (username != null && !"".equals(username) && password != null) {
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
} else {
throw new IllegalArgumentException("Cannot pass null or empty values to constructor");
}
}
需要注意的是,loadUserByUsername(String s)
中传入的s为用户登录或其他业务操作传入的用户名,我们可以通过用户名去数据库中查询该用户的密码,进行比对来决定接下来的业务如何处理。
Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority("ALL"));
这一段代码则是将权限范围添加到该用户的UserDetails信息中,之后的认证也是从UserDetails获取该List来判断用户是否具有某项操作的权限。
至此,整个SpringSecurity的配置
、认证
、授权
、异常处理
都已经实现完成,一般情况下这些内容已经足够实现项目丰富的权限管控要求了,如果还不够用的话,我们还可以通过实现AuthenticationProvider
来自定义更多的认证模式,通过配置好的认证请求链中的顺序来进行更复杂的验证。本文未涉及到多认证模式的内容,需要的可以自行百度相关资料。
AuthenticationProvider
的默认实现是DaoAuthenticationProvider
,
AuthenticationProvider
作为授权方式提供者,用于判断授权有效性,用户有效性,在判断用户是否有效性时,它依赖于UserDetailsService实例,开发人员可以自定义UserDetailsService的实现。
AuthenticationFilter
的默认实现是UsernamePasswordAuthenticationFilter
,
AuthenticationFilter
作为授权过滤器,开发人员可以自定义它的业务逻辑,并把它添加到默认过滤器前或者后去执行,主要用来到授权的持久化,它可以从请求上下文中获取你的username,password等信息,然后去判断它是否符合规则,最后通过authenticate方法去授权。默认的UsernamePasswordAuthenticationFilter
过滤器,主要判断请求方式是否为post,并且对username和password进行了默认值的处理,总之,在这个过滤器里不会涉及到具体业务。
写完上面所提到的所有配置后,我们可以启动一下项目,检查一下是否能够正常启动。
通过上面的配置,项目正常启动。接下来我们需要写几个测试接口,看看具体的配置是否起到了作用。
/**
* @author Nicemorning
* @date Create in 16:40 2020/7/26 0026
*/
@Api(tags = "Demo apis")
@RestController
@RequestMapping("demo")
public class DemoController {
@ApiOperation("Login get token")
@PostMapping("login")
public String login() {
// 需要生成Authentication对象,并将 Authentication 绑定到 SecurityContext,示例项目中没有编写这一部分
List<String> roles = new ArrayList<>();
roles.add(ConstantParams.ROLE_CLAIMS);
return JwtUtil.createToken(ConstantParams.USERNAME, roles);
}
@ApiOperation("auth")
@GetMapping("auth")
public String auth(Integer id) {
return "Auth id: " + id;
}
}
其中包含了两个接口:登录接口(不需要权限)和鉴权接口(需要携带Token)
将登录接口配置为不需要鉴权。
.antMatchers("/demo/login")
启动后浏览器打开http://localhost:8080/api-docs.html
尝试。
在头部中添加Token重新尝试鉴权接口
响应正常。
至此SpringBoot结合SpringSecurity使用JWT实现鉴权和认证已经全部完成,当前项目结构如图所示:
我在本地调试时没有遇到,但是部署到服务器后出现了跨域的问题,解决办法如下:
定义一个CORS配置类,该类标记为配置类并实现WebMvcConfigurer
接口。即可解决
@Configuration
public class CorsFilterConfig implements WebMvcConfigurer {
@Bean
public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(urlBasedCorsConfigurationSource);
}
}
public class JwtUtil implements Serializable {
private static final long serialVersionUID = -649173465054267244L;
private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000;
/**
* description: 创建Token
*
* @param username 用户名
* @return java.lang.String
*/
public static String createToken(String username, List<String> roles) {
HashMap<String, Object> map = new HashMap<>();
map.put(ConstantParams.ROLE_CLAIMS, roles);
return Jwts.builder()
.signWith(SignatureAlgorithm.HS512, ConstantParams.SECRET)
.setClaims(map)
.setIssuer(ConstantParams.ISS)
.setId(String.valueOf("1"))
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME))
.compact();
}
public static String getIdentify(String token) {
return getTokenBody(token).getSubject();
}
public static String getUsername(String token) {
return getTokenBody(token).getSubject();
}
public static Integer getId(String token) {
return Integer.parseInt(getTokenBody(token).getId());
}
/**
* 获取用户角色
*
* @param token token
* @return 用户角色
*/
@SuppressWarnings("unchecked")
public static List<String> getUserRole(String token) {
return (List<String>) getTokenBody(token).get(ConstantParams.ROLE_CLAIMS);
}
/**
* 判断Token是否过期
*
* @param token token
* @return boolean
*/
public static boolean isExpiration(String token) {
return getTokenBody(token).getExpiration().before(new Date());
}
/**
* 解析TOKEN
*
* @param token token
* @return io.jsonwebtoken.Claims
*/
private static Claims getTokenBody(String token) {
return Jwts.parser()
.setSigningKey(ConstantParams.SECRET)
.parseClaimsJws(token)
.getBody();
}
}