本文講述的是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.