微服務-權限認證-Security-jwt實戰

學習思路

  1. 認證流程
  2. 基於Spring-Security原理講解
  3. 用戶名密碼模式具體代碼實現
  4. 根據用戶名密碼模式思考短信驗證碼模式

一、認證流程

用戶登錄、認證流程

  1. 用戶輸入用戶名密碼請求後臺服務
  2. 後臺驗證用戶名密碼是否正確
  3. 正確返回token,錯誤拋出異常
  4. 用戶拿到token請求非白名單服務
  5. 如果token正常訪問成功,如果token異常訪問失敗

題外話:有沒有剛剛入門寫demo的感覺

二、基於Spring-Security原理講解

首先看一下用戶名密碼認證的源碼時序圖

 解釋:

  1. AuthencationManager對usernamePasswordAuthencationToken進行認證
  2. Manager又將token交給了與該token相匹配的AuthenticationProvider(可多個provider)
  3. Provider調用本地UserDetailService獲取用戶信息
  4. provider進行密碼校驗
  5. 校驗通過發送成功事件,進入成功後處理,失敗拋出異常

三、用戶名密碼模式具體代碼實現

由於Security默認實現了用戶名密碼模式

1、引入jar包

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

2、編寫Filter集成AbstractAuthenticationProcessingFilter,實現attemptAuthentication方法返回Authentication即可

public class UsernamePasswordFilter extends AbstractAuthenticationProcessingFilter {

	@Autowired
	LoginExtendProperty loginExtendProperty;
	@Autowired
	UserExtendService userExtendService;


	/**
	 * 重新定義登陸地址
	 * @param authenticationManager
	 */
    public UsernamePasswordFilter(AuthenticationManager authenticationManager, String loginUrl) {
        super(new AntPathRequestMatcher(loginUrl, HttpMethod.POST.name()));
        setAuthenticationManager(authenticationManager);
		setRequiresAuthenticationRequestMatcher(new RequestMatcher(MatcherRule.USERNAME_PASSWORD.getRuleName()));
    }

	/**
	 * 從body取用戶名密碼
	 * @return
	 * @throws AuthenticationServiceException
	 * @throws IOException
	 */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationServiceException, IOException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        if (!userExtendService.validate(request)){
			throw new AuthenticationServiceException("業務驗證異常");
		}
		String json = StreamUtils.copyToString(request.getInputStream(), Charset.forName("UTF-8"));
        logger.info(json);
		JSONObject jsonObject;
        if (null !=json && !"".equals(json) && json.contains(loginExtendProperty.getUsername()) && json.contains(loginExtendProperty.getPassword())){
        	jsonObject=JSONObject.parseObject(json);
		}else {
        	throw new AuthenticationServiceException("用戶名或密碼錯誤");
		}
		return this.getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(
                jsonObject.get(loginExtendProperty.getUsername()), jsonObject.get(loginExtendProperty.getPassword())
        ));
    }
}

其中UsernamePasswordAuthenticationToken內部已經提供實現,我們只需要將用戶名密碼從request裏拿出來new對象即可

3、將配置Filter,編寫config繼承WebSecurityConfigurerAdapter


@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@ComponentScan(basePackages = {"com.murphy.security"})
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthFilterProperty jwtAuthFilterProperty;

    @Autowired
    LoginExtendProperty loginExtendProperty;

    @Autowired
    private SimpleAuthenticatingSuccessHandler simpleAuthenticatingSuccessHandler;

    @Autowired
    private SimpleAuthenticatingFailureHandler simpleAuthenticatingFailureHandler;

    @Autowired
    UserExtendService userExtendService;

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * HTTP請求安全處理
     * token請求授權
     *
     * @param httpSecurity .
     * @throws Exception .
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {


        // 由於使用的是JWT,我們這裏不需要csrf
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry expressionInterceptUrlRegistry
                = httpSecurity.csrf().disable()
                //未授權處理
                .exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
                    logger.error("未授權uri={}",request.getRequestURI());
                    response.setHeader("Access-Control-Allow-Origin", "*");
                    response.setStatus(200);
                    Map<String,Object> map=new HashMap<>(3);
                    map.put("code","401");
                    map.put("message","未授權uri="+request.getRequestURI());
                    map.put("data",Boolean.FALSE);
                    response.setCharacterEncoding("UTF-8");
                    JSONObject.writeJSONString(response.getWriter(),map);
                })
                // 基於token,所以不需要session
                .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests();
        //白名單,無需token
        String[] urls = jwtAuthFilterProperty.getExceptUrl().split(",");
        for (String url : urls) {
            // 對於獲取token的rest api要允許匿名訪問
            expressionInterceptUrlRegistry.antMatchers(url).permitAll();
        }
        expressionInterceptUrlRegistry
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                // 除上面外的所有請求全部需要鑑權認證
                .anyRequest().authenticated();
        // 添加JWT filter
        //將token驗證添加在密碼驗證前面
        httpSecurity.addFilterBefore(getJwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        httpSecurity.addFilterBefore(getUsernamePasswordFilter(), JwtAuthenticationTokenFilter.class);
        httpSecurity.authenticationProvider(new SmsAuthenticationProvider(userExtendService)).addFilterBefore(getSmsFilter(),UsernamePasswordFilter.class);
        // 禁用緩存
        httpSecurity.headers().cacheControl();
    }
	/**
	 * 	密碼加密
	 * @author dongsufeng
	 * @return org.springframework.security.crypto.password.PasswordEncoder
	 * @date 2019/9/17 18:39
	 */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public JwtAuthenticationTokenFilter getJwtAuthenticationTokenFilter() throws Exception {
        return new JwtAuthenticationTokenFilter();
    }

    @Bean
    public AuthenticationManager getManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    @Bean
    public UsernamePasswordFilter getUsernamePasswordFilter() throws Exception {
        UsernamePasswordFilter userLoginFilter = new UsernamePasswordFilter(getManagerBean(), loginExtendProperty.getLoginUrl());
        userLoginFilter.setAuthenticationSuccessHandler(simpleAuthenticatingSuccessHandler);
        userLoginFilter.setAuthenticationFailureHandler(simpleAuthenticatingFailureHandler);
        return userLoginFilter;
    }


}

解釋:

  1. configure()方法是http安全請求處理,主要配置了一些白名單,那些攔截,Filter及順序
  2. getManagerBean()是獲取AuthenticationManager
  3. getUsernamePasswordFilter()裏邊配置了執行成功和失敗的Handler

4、編寫執行成功,失敗的Handler

/**
 * 登陸成功後處理
 * @author dongsufeng
 * @date 2019/12/02 15:01
 * @version 1.0
 */
@Component
public class SimpleAuthenticatingSuccessHandler implements AuthenticationSuccessHandler {

    private static ObjectMapper globalObjectMapper = new ObjectMapper();

    @Autowired
    private JwtService jwtService;

    static {
        globalObjectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
    }

    private Logger logger = LoggerFactory.getLogger(SimpleAuthenticatingSuccessHandler.class);

    private static void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }

	/**
	 * 登陸成功返回token給用戶
	 * @param authentication
	 * @throws IOException
	 * @throws ServletException
	 */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        Map<String, Object> result = new HashMap<>();
        logger.info("登錄成功返回對象==={}",JSONObject.toJSONString(authentication.getPrincipal()));
        result.put("token", jwtService.generateToken(authentication));
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        globalObjectMapper.writeValue(response.getWriter(), result);
        clearAuthenticationAttributes(request);
    }

}

解釋:

  1. 主要實現AuthenticationSuccessHandler接口實現onAuthenticationSuccess方法
  2. 成功後生成token:jwtService.generateToken(authentication)
  3. 封裝token返回
  4. 異常情況實現AuthenticationFailureHandler接口跟成功差不多

5、生成token,看註釋,可以適當將用戶信息放入

/**
     * 生成令牌
     *
     * @param  .
     * @return .
     */
    public String generateToken(Authentication authentication) {
        return Jwts.builder()
                //jwt簽發者
                .setIssuer(jwtPayloadProperties.getIssuer())
                // jwt所面向的用戶
                .setSubject(authentication.getName())
                //接收jwt的一方
                .setAudience(jwtPayloadProperties.getAudience())
                //生效時間
                .setNotBefore(new Date(System.currentTimeMillis() - jwtPayloadProperties.getNotBeforeMinute() * 60 * 1000))
                //失效時間
                .setExpiration(new Date(System.currentTimeMillis() + jwtPayloadProperties.getExpirationMinute() * 60 * 1000))
                //簽發時間
                .setIssuedAt(new Date())
                //祕鑰簽名
                .signWith(SignatureAlgorithm.HS512, jwtPayloadProperties.getSecret())
                .claim("userDTO", JSON.toJSONString(authentication.getPrincipal()))
                .compact();
    }

6、作爲使用者應該實現UserDetailsService接口實現loadUserByUsername()

@Component
@Log4j2
public class UserDetailsService implements UserExtendService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("18888888888".equals(username)) {
            User user = new User("18888888888", PasswordEncoderFactories.createDelegatingPasswordEncoder().encode("123456"), "userid1111101111");
            user.setMobile("13333333333");
            return user;
        }else {
            return null;
        }
    }
}

這裏主要通過username查詢用戶信息,這個必須實現,主要用於providr裏獲取用戶信息

7、用戶拿到token後可以將token放入header然後請求相應的服務了,進入驗證環境

public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtAuthFilterProperty jwtAuthFilterProperty;

    /**
     * 過濾器邏輯
     *
     * @param request  .
     * @param response .
     * @param chain    .
     * @throws ServletException .
     * @throws IOException      ,
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String authHeader = request.getHeader(this.jwtAuthFilterProperty.getHeader());
        String tokenHead = this.jwtAuthFilterProperty.getTokenHead();
        if (authHeader != null && authHeader.startsWith(tokenHead)) {
            String authToken = authHeader.substring(tokenHead.length());
            if (jwtService.validateToken(authToken)) {
                Claims claimsFromToken = jwtService.getClaimsFromToken(authToken);
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(claimsFromToken.getSubject(), null, null);
                String  userDTO = claimsFromToken.get("userDTO", String.class);
                log.info("=======lanjie={}",userDTO);
                request.setAttribute("userInfoDTO", userDTO);
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }
}

解釋:

  1. 正常實現Filter,然後對token進行解析驗證
  2. 將UsernamePasswordAuthenticationToken放入SecurityContext即可

至此整個用戶名密碼驗證流程結束

四、根據用戶名密碼模式思考短信驗證碼模式

我們瞭解了用戶名密碼整體流程之後,再來看如果是短信驗證碼該怎麼做

我們來模仿整個看看思路:

  1. 寫一個短信的Token跟UsernamePasswordAuthenticationToken,用於設置一些用戶名等參數
  2. 寫一個provider去獲取用戶信息,並進行相關的校驗

然後其它的就跟用戶名密碼一樣了,,

1、編寫SmsAuthonticationToken繼承AbstractAuthenticationToken(直接複製UsernamePasswordAuthonticationToken將裏邊的密碼參數刪掉就可以了)

詳情可看源碼:com.murphy.security.token.SmsAuthenticationToken

2、編寫provider實現AuthenticationProvider

public class SmsAuthenticationProvider implements AuthenticationProvider {

//    private UserDetailsService userDetailsService;
    private UserExtendService userExtendService;
    public SmsAuthenticationProvider(){}

    public SmsAuthenticationProvider( UserExtendService userExtendService){
//        this.userDetailsService=userDetailsService;
        this.userExtendService = userExtendService;
    }

	@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
        /**
         * 調用 {@link UserDetailsService}
         */
        UserDetails user = userExtendService.loadUserByUsername((String)authenticationToken.getPrincipal());
        if (Objects.isNull(user)) {
            throw new InternalAuthenticationServiceException("手機號不存在");
        }
        if (!userExtendService.validate(authenticationToken.getRequest())){
            throw new InternalAuthenticationServiceException("驗證碼錯誤");
        }
        SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(user, user.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;
    }

	@Override
	public boolean supports(Class<?> authentication) {
		return SmsAuthenticationToken.class.isAssignableFrom(authentication);
	}

}

這裏主要看userExtendService.validate(authenticationToken.getRequest())

驗證碼校驗屬於業務,有各種各樣的實現方案(比如放redis裏)這裏可以提供接口,讓使用方實現接口,我們在這裏調用一下

public interface UserExtendService extends UserDetailsService{
    /**
     * 業務校驗
     * @param request
     * @return
     */
    default boolean validate(HttpServletRequest request){
        return true;
    };
}

然後再根據用戶名密碼那套流程,將usernamePasswordAuthenticationToken換成SmsAuthenticationToken;將SmsAuthenticationProvider放入配置即可

具體配置文件可看源碼:com.murphy.security.config.WebSecurityConfig

這樣如果再有其它的認證方式,畫瓢即可,驗證可以給默認,然後再暴露給使用者,可以重寫

源碼地址:https://gitee.com/carpentor/spring-cloud-example

公衆號主要記錄各種源碼、面試題、微服務技術棧,幫忙關注一波,非常感謝

 

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