SpringBoot整合Spring Security + JWT

版本

  • SpringBoot:2.2.5.RELEASE
  • jjwt:0.9.0
  • Jdk:1.8
  • Maven:3.5.2
  • Idea:2019.3

依賴

項目pom.xml文件中引入Spring Security和Jwt的依賴座標

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

準備

整合Redis,將登錄用戶信息緩存進Redis,整合參考鏈接部分

食用

0:配置Spring Security
這是Spring Security的參考配置項,主要包括以下內容:URL攔截、匿名用戶訪問無權限資源處理器(AuthenticationEntryPointHandler)、登出處理器(LogoutSuccessHandler)、登出URL、過濾器(TokenFilter)、UserDetailsService實現類等

  • 指定不同URL訪問權限
  • 指定密碼加密方式
  • 指定訪問無權資源處理器
  • 指定登出URL及處理器
  • 指定UserDetailsService實現
  • 指定自定義過濾器
import com.liu.gardenia.security.security.filter.TokenFilter;
import com.liu.gardenia.security.security.handler.AuthenticationEntryPointHandler;
import com.liu.gardenia.security.security.handler.MyLogoutSuccessHandler;
import com.liu.gardenia.security.security.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * Spring Security配置
 *
 * @author liujiazhong
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AuthenticationEntryPointHandler unauthorizedHandler;
    private final MyLogoutSuccessHandler logoutSuccessHandler;
    private final TokenFilter tokenFilter;

    public SecurityConfig(AuthenticationEntryPointHandler unauthorizedHandler, MyLogoutSuccessHandler logoutSuccessHandler,
                          TokenFilter tokenFilter) {
        this.unauthorizedHandler = unauthorizedHandler;
        this.logoutSuccessHandler = logoutSuccessHandler;
        this.tokenFilter = tokenFilter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsServiceImpl();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * hasRole                  如果有參數,參數表示角色,則其角色可以訪問
     * hasAnyRole               如果有參數,參數表示角色,則其中任何一個角色可以訪問
     * hasAuthority             如果有參數,參數表示權限,則其權限可以訪問
     * hasAnyAuthority          如果有參數,參數表示權限,則其中任何一個權限可以訪問
     * hasIpAddress             如果有參數,參數表示IP地址,如果用戶IP和參數匹配,則可以訪問
     * permitAll                用戶可以任意訪問
     * anonymous                匿名可以訪問
     * rememberMe               允許通過remember-me登錄的用戶訪問
     * denyAll                  用戶不能訪問
     * authenticated            用戶登錄後可訪問
     * fullyAuthenticated       用戶完全認證可以訪問(非remember-me下自動登錄)
     * access                   SpringEl表達式結果爲true時可以訪問
     * anyRequest               匹配所有請求路徑
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                .antMatchers("/api/user/login").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/**/*.ico"
                ).permitAll()
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/api/user/logout").logoutSuccessHandler(logoutSuccessHandler);
        httpSecurity.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService()).passwordEncoder(bCryptPasswordEncoder());
    }
}

1:實現匿名用戶訪問無權限資源異常處理器
無權訪問時可以根據自己業務需求做相應操作,例如拋出異常、返回提示信息給前端、打印日誌等

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint, Serializable {
    private static final long serialVersionUID = 1L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        log.warn("認證失敗,無法訪問系統資源:{}", request.getRequestURI());
        ServletUtils.renderString(response, "無權訪問");
    }

}

2:實現登出處理器
實現登出時的相關操作,例如從redis中移除緩存的登陸用戶信息、把登出成功的提示信息返回給前端等

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

    private final TokenService tokenService;

    public MyLogoutSuccessHandler(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        UserInfo userInfo = tokenService.getUserInfo(request);
        if (Objects.nonNull(userInfo)) {
            tokenService.removeUserInfo(userInfo.getUuid());
        }
        ServletUtils.renderString(response, "logout success.");
    }

}

3:自定義過濾器進行授權操作
從Redis緩存中取出用戶信息,生成Spring Security身份認證令牌放入Security上下文中,這裏可以選擇不同的授權方式

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class TokenFilter extends OncePerRequestFilter {

    private final TokenService tokenService;

    public TokenFilter(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
            throws IOException, ServletException {
        log.info("into token filter...");
        UserInfo userInfo = tokenService.getUserInfo(request);
        if (Objects.nonNull(userInfo) && Objects.isNull(SecurityUtils.getAuthentication())) {
            tokenService.verifyToken(userInfo);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }

}

4:準備登錄用戶信息實體
這裏需要實現Spring Security中的UserDetails接口,重寫接口中的一些方法,比如指定用戶名和密碼等,可以根據業務情況告訴Security當前賬戶是否過期、是否鎖定、是否禁用等信息,還可以直接通過getAuthorities()方法把該賬號對應的角色和權限返回給Security,也可以自己手動實現權限驗證,我這裏選擇手動實現

/**
 * @author liujiazhong
 */
@Getter
@Setter
public class UserInfo implements UserDetails {

    private Long userId;

    private String username;

    private String password;

    private Set<String> permissions;

    private String uuid;

    private Long loginTime;

    private Long expireTime;

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
}

5:實現UserDetailsService
這裏重寫接口中的loadUserByUsername()方法,從數據庫查詢到用戶信息和該用戶對應的權限列表,返回UserInfo

/**
 * @author liujiazhong
 */
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfo(username);
        if (Objects.isNull(userInfo)) {
            throw new RuntimeException("user not found.");
        }
        // todo check user: status...

        return userInfo;
    }

    private UserInfo userInfo(String username) {
        // todo find userInfo from mysql
        UserInfo userInfo = null;
        if (Objects.equals("liu", username)) {
            userInfo = new UserInfo();
            userInfo.setUserId(1001L);
            userInfo.setUsername("liu");
            userInfo.setPassword(SecurityUtils.encryptPassword("1111"));
            userInfo.setPermissions(userPermissionByUserId(userInfo.getUserId()));
        }
        return userInfo;
    }

    private Set<String> userPermissionByUserId(Long userId) {
        // todo find permissions from mysql
        Set<String> permissions = new HashSet<>(1);
        permissions.add("*:*:*");
        return permissions;
    }

}

6:Jwt相關
這裏涉及到了Token的生成與解析,用戶信息的緩存等操作,RedisCache的實現參考連接部分整合Redis

/**
 * token驗證處理
 *
 * @author liujiazhong
 */
@Component
public class TokenService {

    protected static final long MILLIS_SECOND = 1000;
    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;

    private final RedisCache redisCache;
    private final TokenConfig tokenConfig;

    public TokenService(RedisCache redisCache, TokenConfig tokenConfig) {
        this.redisCache = redisCache;
        this.tokenConfig = tokenConfig;
    }

    public UserInfo getUserInfo(HttpServletRequest request) {
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        Claims claims = parseToken(token);
        String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
        String userKey = getTokenKey(uuid);
        Object value = redisCache.getCacheObject(userKey);
        if (Objects.isNull(value)) {
            return null;
        }
        if (value instanceof UserInfo) {
            return (UserInfo) value;
        }
        throw new RuntimeException("UserInfo Cache Type Error.");
    }

    public void setUserInfo(UserInfo userInfo) {
        if (Objects.nonNull(userInfo) && StringUtils.isNotEmpty(userInfo.getUuid())) {
            refreshToken(userInfo);
        }
    }

    public void removeUserInfo(String uuid) {
        if (StringUtils.isNotEmpty(uuid)) {
            String userKey = getTokenKey(uuid);
            redisCache.deleteObject(userKey);
        }
    }

    public String createToken(UserInfo userInfo) {
        String uuid = IdUtils.uuid();
        userInfo.setUuid(uuid);
        refreshToken(userInfo);

        Map<String, Object> claims = new HashMap<>(1);
        claims.put(Constants.LOGIN_USER_KEY, uuid);
        return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, tokenConfig.getSecret()).compact();
    }

    public void verifyToken(UserInfo userInfo) {
        long expireTime = userInfo.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
            refreshToken(userInfo);
        }
    }

    public void refreshToken(UserInfo userInfo) {
        userInfo.setLoginTime(System.currentTimeMillis());
        userInfo.setExpireTime(userInfo.getLoginTime() + tokenConfig.getExpireTime() * MILLIS_MINUTE);
        String userKey = getTokenKey(userInfo.getUuid());
        redisCache.setCacheObject(userKey, userInfo, tokenConfig.getExpireTime(), TimeUnit.MINUTES);
    }

    public String getUsernameFromToken(String token) {
        Claims claims = parseToken(token);
        return claims.getSubject();
    }

    private Claims parseToken(String token) {
        return Jwts.parser().setSigningKey(tokenConfig.getSecret()).parseClaimsJws(token).getBody();
    }

    private String getToken(HttpServletRequest request) {
        String token = request.getHeader(tokenConfig.getHeader());
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

    private String getTokenKey(String uuid) {
        return Constants.LOGIN_TOKEN_KEY + uuid;
    }
}

7:鑑權
鑑權操作的實現,從緩存中獲取到登錄用戶信息,判定當前用戶擁有的權限中是否包含該資源的權限

/**
 * @author liujiazhong
 */
@Slf4j
@Service("ps")
public class PermissionServiceImpl {

    private static final String ALL_PERMISSION = "*:*:*";

    private final TokenService tokenService;

    public PermissionServiceImpl(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    public boolean hasPermission(String permission) {
        if (StringUtils.isBlank(permission)) {
            return false;
        }
        UserInfo info = tokenService.getUserInfo(ServletUtils.getRequest());
        if (Objects.isNull(info) || CollectionUtils.isEmpty(info.getPermissions())) {
            return false;
        }
        return check(info.getPermissions(), permission);
    }

    private boolean check(Set<String> permissions, String permission) {
        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
    }

}

8:給資源加權限
使用@PreAuthorize註解標記資源,“ps”爲步驟7中的鑑權實現類,“hasPermission”爲鑑權方法,“gardenia:demo:info”爲自定義的資源權限

/**
 * @author liujiazhong
 */
@RestController
@RequestMapping("api/demo")
public class DemoController {

    @GetMapping("hello")
    public String hello() {
        return "hello";
    }

    @PreAuthorize("@ps.hasPermission('gardenia:demo:info')")
    @GetMapping("info")
    public String info() {
        return "liu";
    }

}

9:登錄
認證通過後直接返回Token

/**
 * @author liujiazhong
 */
@Slf4j
@Service
public class UserLoginServiceImpl implements UserLoginService {

    private final TokenService tokenService;
    private final AuthenticationManager authenticationManager;

    public UserLoginServiceImpl(TokenService tokenService, AuthenticationManager authenticationManager) {
        this.tokenService = tokenService;
        this.authenticationManager = authenticationManager;
    }

    @Override
    public String login(String username, String password) {
        Authentication authentication;
        try {
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            if (e instanceof BadCredentialsException) {
                throw new PasswordException();
            } else {
                throw new RuntimeException(e.getMessage());
            }
        }
        UserInfo userInfo = (UserInfo) authentication.getPrincipal();
        return tokenService.createToken(userInfo);
    }

}

補充

補充上文中使用到的幾個自定義工具類

IdUtils

public class IdUtils {

    public static String uuid() {
        return UUID.randomUUID().toString();
    }

}

SecurityUtils

public class SecurityUtils {

    public static Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    public static String encryptPassword(String password) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.encode(password);
    }

    public static boolean validPassword(String password, String encodedPassword) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.matches(password, encodedPassword);
    }

}

ServletUtils

@Slf4j
public class ServletUtils {

    public static HttpServletRequest getRequest() {
        return getRequestAttributes().getRequest();
    }

    public static ServletRequestAttributes getRequestAttributes() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return (ServletRequestAttributes) attributes;
    }

    public static void renderString(HttpServletResponse response, String string) {
        try {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        } catch (Exception e) {
            log.error("ServletUtils.renderString exception...", e);
        }
    }

}

鏈接

SpringBoot整合Spring Data Redis:https://blog.csdn.net/momo57l/article/details/105427898
Spring Security:https://spring.io/projects/spring-security#overview
CSRF:https://docs.spring.io/spring-security/site/docs/5.3.2.BUILD-SNAPSHOT/reference/html5/#csrf
JWT:https://jwt.io/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章