springboot + springSecurity + jwt 实现用户访问认证和授权
本文讲述的是springboot集成springSecurity和JWT的实现。
前后端分离目前已成为互联网项目开发的业界标准,其核心思想就是前端(APP、小程序、H5页面等)通过调用后端的API接口,提交及返回JSON数据进行交互。
在前后端分离项目中,首先要解决的就是登录及授权的问题。传统的session认证限制了应用的扩展能力,无状态的JWT认证方法应运而生,该认证机制特别适用于分布式站点的单点登录(SSO)场景。
一,导入SpringSecurity与JWT的相关依赖
<!--Security框架--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- jwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.10.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.10.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.10.6</version> </dependency>
二,定义SpringSecurity需要的基础处理类
application-dev.properties 加入jwt配置信息
##jwt # 令牌key jwt.header = Authorization # 令牌前缀 jwt.token-start-with = Bearer # 使用Base64对该令牌进行编码 jwt.base64-secret = U2FsdGVkX1/3Ox76xzrqllLe1lIgoHycDTgwVYrFQTPhG9V1lQPnLerFS/tmN1PzrQmx5243Nu9/iJf88neqOA== # 令牌过期时间 此处单位/毫秒 jwt.token-validity-in-seconds = 14400000
创建一个jwt的配置类,并注入Spring,便于程序中调用
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.config * @ClassName: JwtSecurityProperties * @Author: xxx * @Description: JWT配置类 * @Date: 2021/2/18 10:55 上午 */ @Data @Configuration @ConfigurationProperties(prefix = "jwt") public class JwtSecurityProperties { /** Request Headers : Authorization */ private String header; /** 令牌前缀,最后留个空格 Bearer */ private String tokenStartWith; /** Base64对该令牌进行编码 */ private String base64Secret; /** 令牌过期时间 此处单位/毫秒 */ private Long tokenValidityInSeconds; /**返回令牌前缀 */ public String getTokenStartWith() { return tokenStartWith + " "; } }
定义无权限访问类
import com.fasterxml.jackson.databind.ObjectMapper; import com.lq.pys.base.core.BDic; import com.lq.pys.base.core.BaseOut; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.common * @ClassName: JwtAccessDeniedHandler * @Author: xxx * @Description: jwt无权限访问类 * @Date: 2021/2/18 11:28 上午 */ @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { // 这个是自定义的返回对象,看各自需求 BaseOut baseOut = new BaseOut(); baseOut.setCode(BDic.FAIL); baseOut.setMessage("无权限查看此页面,请联系管理员!"); baseOut.setTimestamp(Long.valueOf(System.currentTimeMillis()).toString()); response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_OK); try { ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(response.getOutputStream(), baseOut); } catch (Exception e) { throw new ServletException(); } } }
定义认证失败处理类
import com.fasterxml.jackson.databind.ObjectMapper; import com.lq.pys.base.core.BDic; import com.lq.pys.base.core.BaseOut; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.common * @ClassName: JwtAuthenticationEntryPoint * @Author: xxx * @Description: JWT认证失败处理类 * @Date: 2021/2/18 11:31 上午 */ @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { BaseOut baseOut = new BaseOut(); baseOut.setCode(BDic.FAIL); baseOut.setMessage("无权限查看此页面,请联系管理员"); baseOut.setTimestamp(Long.valueOf(System.currentTimeMillis()).toString()); response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_OK); try { ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(response.getOutputStream(), baseOut); } catch (Exception e) { throw new ServletException(); } } }
三,构建JWT token工具类
工具类实现创建token与校验token功能
import com.lq.pys.base.config.JwtSecurityProperties; import com.lq.pys.base.core.UserInfo; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.InitializingBean; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; import java.security.Key; import java.util.*; import java.util.stream.Collectors; /** * @ProjectName: git-dev * @Package: com.lq.pys.util * @ClassName: JwtTokenUtils * @Author: xxx * @Description: JWT * @Date: 2021/2/18 11:01 上午 */ @Slf4j @Component public class JwtTokenUtils implements InitializingBean { private final JwtSecurityProperties jwtSecurityProperties; private static final String AUTHORITIES_KEY = "auth"; private Key key; public JwtTokenUtils(JwtSecurityProperties jwtSecurityProperties) { this.jwtSecurityProperties = jwtSecurityProperties; } @Override public void afterPropertiesSet() { byte[] keyBytes = Decoders.BASE64.decode(jwtSecurityProperties.getBase64Secret()); this.key = Keys.hmacShaKeyFor(keyBytes); } public String createToken (Map<String, Object> claims) { return Jwts.builder() .claim(AUTHORITIES_KEY, claims) .setId(UUID.randomUUID().toString()) .setIssuedAt(new Date()) .setExpiration(new Date((new Date()).getTime() + jwtSecurityProperties.getTokenValidityInSeconds())) .compressWith(CompressionCodecs.DEFLATE) .signWith(key, SignatureAlgorithm.HS512) .compact(); } public Date getExpirationDateFromToken(String token) { Date expiration; try { final Claims claims = getClaimsFromToken(token); expiration = claims.getExpiration(); } catch (Exception e) { expiration = null; } return expiration; } public Authentication getAuthentication(String token) { Claims claims = Jwts.parser() .setSigningKey(key) .parseClaimsJws(token) .getBody(); Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); HashMap map =(HashMap) claims.get("auth"); UserInfo principal = new UserInfo(map); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } public boolean validateToken(String authToken) { try { Jwts.parser().setSigningKey(key).parseClaimsJws(authToken); return true; } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { log.error("token失效",e); } catch (ExpiredJwtException e) { log.error("token过期",e); } catch (UnsupportedJwtException e) { log.error("无效的token",e); } catch (IllegalArgumentException e) { log.error("处理token异常.",e); } return false; } private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser() .setSigningKey(key) .parseClaimsJws(token) .getBody(); } catch (Exception e) { claims = null; } return claims; } }
四,实现token验证的过滤器
该类继承OncePerRequestFilter,它能够确保在一次请求中只通过一次filter。该类使用JwtTokenUtils工具类进行token校验。
import com.lq.pys.base.common.SpringContextHolder; import com.lq.pys.base.config.JwtSecurityProperties; import com.lq.pys.util.JwtTokenUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; 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; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.filter * @ClassName: JwtAuthenticationTokenFilter * @Author: xxx * @Description: JWT过滤器 * @Date: 2021/2/18 11:07 上午 */ @Component @Slf4j public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtils jwtTokenUtils; public JwtAuthenticationTokenFilter(JwtTokenUtils jwtTokenUtils) { this.jwtTokenUtils = jwtTokenUtils; } @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { JwtSecurityProperties jwtSecurityProperties = SpringContextHolder.getBean(JwtSecurityProperties.class); String requestRri = httpServletRequest.getRequestURI(); //获取request token String token = null; String bearerToken = httpServletRequest.getHeader(jwtSecurityProperties.getHeader()); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtSecurityProperties.getTokenStartWith())) { token = bearerToken.substring(jwtSecurityProperties.getTokenStartWith().length()); } if (StringUtils.hasText(token) && jwtTokenUtils.validateToken(token)) { Authentication authentication = jwtTokenUtils.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestRri); } else { log.debug("no valid JWT token found, uri: {}", requestRri); } filterChain.doFilter(httpServletRequest, httpServletResponse); } }
根据SpringBoot官方让重复执行的filter实现一次执行过程的解决方案,参见官网地址:https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-disable-registration-of-a-servlet-or-filter
需在SpringBoot启动类中,加入以下代码:
/** * @Description: * @param filter: * @return: org.springframework.boot.web.servlet.FilterRegistrationBean * @author: xxx * @date: 2021/2/18 11:14 上午 */ @Bean public FilterRegistrationBean registration(JwtAuthenticationTokenFilter filter) { FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); registration.setEnabled(false); return registration; }
五,SpringSecurity的关键配置
SpringBoot推荐使用配置类来代替xml配置,该类中涉及了以上几个bean来供security使用
- JwtAccessDeniedHandler :无权限访问
- jwtAuthenticationEntryPoint :认证失败处理
- jwtAuthenticationTokenFilter :token验证的过滤器
import com.lq.pys.base.exception.JwtAccessDeniedHandler; import com.lq.pys.base.exception.JwtAuthenticationEntryPoint; import com.lq.pys.base.filter.JwtAuthenticationTokenFilter; import com.lq.pys.util.JwtTokenUtils; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; 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.config.http.SessionCreationPolicy; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.config * @ClassName: WebSecurityConfig * @Author: xxx * @Description: SpringSecurity关键配置 * @Date: 2021/2/18 11:20 上午 */ @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtTokenUtils jwtTokenUtils; public WebSecurityConfig(JwtAccessDeniedHandler jwtAccessDeniedHandler, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtTokenUtils jwtTokenUtils) { this.jwtAccessDeniedHandler = jwtAccessDeniedHandler; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.jwtTokenUtils = jwtTokenUtils; } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity // 禁用 CSRF .csrf().disable() // 授权异常 .exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) // 不创建会话 .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 放行swagger .antMatchers("/swagger-ui.html").permitAll() .antMatchers("/swagger-resources/**").permitAll() .antMatchers("/webjars/**").permitAll() .antMatchers("/*/api-docs").permitAll() // 跨域请求会先进行一次options请求 必须放行的OPTIONS请求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //允许匿名及登录用户访问 .antMatchers("/api/auth/**", "/error/**").permitAll() // 不需要token的访问 .antMatchers("/admin/**").permitAll() .antMatchers("/login/**").permitAll() .antMatchers("/sms/**").permitAll() .antMatchers("/membersTypeInfo/**").permitAll() .antMatchers("/membersClassInfo/**").permitAll() .antMatchers("/membersRightsInfo/**").permitAll() .antMatchers("/oss/**").permitAll() .antMatchers("/storeUser/**").permitAll() .antMatchers("/imgConfigure/**").permitAll() .antMatchers("/userMembersInfo/**").permitAll() // .antMatchers("/sms/**").permitAll() // 所有请求都需要认证 .anyRequest().authenticated(); // 禁用缓存 httpSecurity.headers().cacheControl(); // 添加JWT filter httpSecurity.apply(new TokenConfigurer(jwtTokenUtils)); } public class TokenConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { private final JwtTokenUtils jwtTokenUtils; public TokenConfigurer(JwtTokenUtils jwtTokenUtils){ this.jwtTokenUtils = jwtTokenUtils; } @Override public void configure(HttpSecurity http) { JwtAuthenticationTokenFilter customFilter = new JwtAuthenticationTokenFilter(jwtTokenUtils); http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); } } }
六,编写Controller/Service进行测试
登录后可以使用 LoginUserUtilV3.getLoginUser().getUserOperationId(); 获取用户信息
/** * @ProjectName: git-dev * @Package: com.lq.pys.system.controller * @ClassName: AdminLoginController * @Author: xxx * @Description: 后台登录 控制器 * @Date: 2021/2/8 1:23 下午 */ @RestController @RequestMapping("/admin/login/") public class AdminLoginController extends ZyBaseController { @Autowired private LoginService loginService; /** * @param in: * @Description: Admin 密码登录 * @return: com.lq.pys.base.core.BaseOut * @author: xxx * @date: 2021/2/8 10:18 上午 */ @PostMapping("password") public BaseOut password(@RequestBody @Validated(value = {PasswordLogin.class}) LoginIn in) { in.setLoginType(LoginDic.LOGIN_TYPE.PASSWORD); LoginAdminUserOut loginAdminUserOut = loginService.adminLogin(in); return setSuccessBaseOut(loginAdminUserOut); } }
/** * @ProjectName: git-dev * @Package: com.lq.pys.system.service * @ClassName: LoginService * @Author: xxx * @Description: 登录业务处理 * @Date: 2021/2/8 9:33 上午 */ @Slf4j @Service public class LoginService { @Autowired protected SysUserService sysUserService; @Autowired protected CaptchaService captchaService; @Autowired private JwtTokenUtils jwtTokenUtils; /** * @Description: Admin 用户登录 * @param in: * @return: com.lq.pys.system.login.out.LoginAdminUserOut * @author: xxx * @date: 2021/2/8 10:44 上午 */ public LoginAdminUserOut adminLogin(LoginIn in) { /** 校验用户名/密码/图片验证码 */ SysUser sysUser = sysUserService.getUserByAccount(in.getAccount()); Optional.ofNullable(sysUser).orElseThrow(()->new BusinessException("用户不存在,不允许登录")); Optional.ofNullable(sysUser.getPassword()).filter(s->sysUser.getPassword().equals(SecurityUtil.pwdEncrypt(in.getPassword()))).orElseThrow(()->new BusinessException("登录密码错误,请重新输入")); // captchaService.check(in.getImageCodekey(),in.getImageCode()); LoginAdminUserOut loginAdminUserOut = new LoginAdminUserOut(); /** 设置用户信息 */ AdminUserOut adminUserOut = new AdminUserOut(); BeanUtils.copyProperties(sysUser,adminUserOut); loginAdminUserOut.setUserInfo(adminUserOut); /** 设置用户权限 */ /** 设置token */ String token = jwtTokenUtils.createToken(convertToMap(adminUserOut)); loginAdminUserOut.setToken(token); return loginAdminUserOut; } }
使用IDEA Rest Client测试如下:
无token和token失效返回的错误信息:
使用到的用户对象的类:
import com.fasterxml.jackson.annotation.JsonIgnore; import com.lq.pys.system.dto.sys.ZySysRole; import lombok.Data; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.CollectionUtils; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.Set; @Data public class LoginUserV3 implements UserDetails { /** * 业务ID */ private String userOperationId; /** * 登录帐户 */ private String account; /** * 手机号 */ private String phone; /** * 密码 */ private String password; /** * 邀请码 */ private String invitation; /** * 创建时间 */ private Date createTime; /** * 是否是章鱼管理员 */ private Boolean isZyAdmin = false; /** * 登陆UUID */ private String loginUUID; private Set<ZySysRole> sysRoles; private Set<String> permissions; @JsonIgnore @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collection = new HashSet<>(); if (!CollectionUtils.isEmpty(sysRoles)) { sysRoles.parallelStream().forEach(role -> { if (role.getRole_name().startsWith("ROLE_")) { collection.add(new SimpleGrantedAuthority(role.getRole_name())); } else { collection.add(new SimpleGrantedAuthority("ROLE_" + role.getRole_name())); } }); } if (!CollectionUtils.isEmpty(permissions)) { for (String per : permissions){ collection.add(new SimpleGrantedAuthority(per)); } } return collection; } @Override public String getUsername() { return getAccount(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
全局获取用户对象的工具类
import com.alibaba.fastjson.JSONObject; import com.lq.pys.base.core.UserInfo; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; public class LoginUserUtilV3 { /** * @Description: 获取登录信息 * @param : * @return: com.lq.pys.system.login.LoginUserV3 * @author: xxx * @date: 2021/2/21 19:04 上午 */ public static LoginUserV3 getLoginUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication instanceof UsernamePasswordAuthenticationToken) { UsernamePasswordAuthenticationToken authenticationToken = (UsernamePasswordAuthenticationToken) authentication; Object str = authenticationToken.getPrincipal(); UserInfo userInfo = (UserInfo) str; LoginUserV3 loginUserV3 = JSONObject.parseObject(JSONObject.toJSONString(userInfo), LoginUserV3.class); return loginUserV3; } return null; } }
用户实体类:
/** * @ProjectName: git-dev * @Package: com.lq.pys.util * @ClassName: UserInfo * @Author: xxx * @Description: 用户信息类 * @Date: 2021/2/18 11:01 上午 */ @Data public class UserInfo implements Serializable{ private static final long serialVersionUID = 4768132985889604776L; /** 用户ID */ private Long id; /** 用户业务id */ private String userOperationId; /** 用户账号 */ private String account; /** 用户密码 */ private String password; public UserInfo(HashMap map){ this.userOperationId = map.get("operationId").toString(); this.account = map.get("account").toString(); this.password = map.get("password").toString(); } }
解决跨域问题:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; /** * @ProjectName: git-dev * @Package: com.lq.pys.base.config * @ClassName: CorsConfig * @Author: xxx * @Description: 解决跨域问题 * @Date: 2021/2/18 1:39 下午 */ @Configuration public class CorsConfig { private CorsConfiguration buildConfig() { CorsConfiguration corsConfiguration = new CorsConfiguration(); /** * 你需要跨域的地址 注意这里的 127.0.0.1 != localhost * 表示只允许http://localhost:8080地址的访问(重点哦!!!!) * corsConfiguration.addAllowedOrigin("http://localhost:8080"); */ //允许所有域名进行跨域调用 corsConfiguration.addAllowedOrigin("*"); //放行全部原始头信息 corsConfiguration.addAllowedHeader("*"); //允许所有请求方法跨域调用 corsConfiguration.addAllowedMethod("*"); //允许跨越发送cookie corsConfiguration.setAllowCredentials(true); return corsConfiguration; } @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); //配置 可以访问的地址 source.registerCorsConfiguration("/**", buildConfig()); // 4 return new CorsFilter(source); }
麻麻思day.