前後端分離項目 — 基於SpringSecurity OAuth2.0用戶認證

1、前言

現在的好多項目都是基於APP移動端以及前後端分離的項目,之前基於Session的前後端放到一起的項目已經慢慢失寵並淡出我們視線,尤其是當基於SpringCloud的微服務架構以及Vue、React單頁面應用流行起來後,情況更甚。爲此基於前後端分離的項目用戶認證也受到衆人關注的一個焦點,不同以往的基於Session用戶認證,基於Token的用戶認證是目前主流選擇方案(至於什麼是Token認證,網上有相關的資料,大家可以看看),而且基於Java的兩大認證框架有Apache Shiro和SpringSecurity,我在此就不討論孰優孰劣的,大家可自行百度看看,本文主要討論的是基於SpringSecurity的用戶認證。

2、準備工作

創建三個項目第一個項目awbeci-ssb是主項目包含兩個子項目awbeci-ssb-api和awbeci-ssb-core,並且引入相關SpringSecurity jar包,如下所示:
下面是我的項目目錄結構,代碼我會在最後放出來

clipboard.png

clipboard.png

clipboard.png

3、配置SpringSecurity OAuth2.0 資源服務和認證服務

1)什麼是資源服務?

資源服務一般是配置用戶名密碼或者手機號驗證碼、社交登錄等等用戶認證方式的配置以及一些靜態文件地址和相關請求地址設置要不要認證等等作用。

2)什麼是認證服務?

認證服務是配置認證使用的方式,如Redis、JWT等等,還有一個就是設置ClientId和ClinetSecret,只有正確的ClientId和ClinetSecret才能獲取Token。

3)首先我們創建兩個類一個繼承AuthorizationServerConfigurerAdapter的SsbAuthorizationServerConfig作爲認證服務類和一個繼承ResourceServerConfigurerAdapter的SsbResourceServerConfig資源服務類,這兩個類實現好,大概已經完成50%了,代碼如下:

@Configuration
@EnableAuthorizationServer
public class SsbAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    public SsbAuthorizationServerConfig(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.userDetailsService(userDetailsService);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()//配置內存中,也可以是數據庫
                .withClient("awbeci")//clientid
                .secret("awbeci-secret")
                .accessTokenValiditySeconds(3600)//token有效時間  秒
                .authorizedGrantTypes("refresh_token", "password", "authorization_code")//token模式
                .scopes("all")//限制允許的權限配置

                .and()//下面配置第二個應用   (不知道動態的是怎麼配置的,那就不能使用內存模式,應該使用數據庫模式來吧)
                .withClient("test")
                .scopes("testSc")
                .accessTokenValiditySeconds(7200)
                .scopes("all");
    }
}
@Configuration
@EnableResourceServer
public class SsbResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    protected AuthenticationSuccessHandler ssbAuthenticationSuccessHandler;

    @Autowired
    protected AuthenticationFailureHandler ssbAuthenticationFailureHandler;

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        // 所以在我們的app登錄的時候我們只要提交的action,不要跳轉到登錄頁
        http.formLogin()
                //登錄頁面,app用不到
                //.loginPage("/authentication/login")
                //登錄提交action,app會用到
                // 用戶名登錄地址
                .loginProcessingUrl("/form/token")
                //成功處理器 返回Token
                .successHandler(ssbAuthenticationSuccessHandler)
                //失敗處理器
                .failureHandler(ssbAuthenticationFailureHandler);

        http
                // 手機驗證碼登錄
                .apply(smsCodeAuthenticationSecurityConfig)
                .and()
                .authorizeRequests()
                //手機驗證碼登錄地址
                .antMatchers("/mobile/token", "/email/token")
                .permitAll()
                .and()
                .authorizeRequests()
                .antMatchers(
                        "/register",
                        "/social/**",
                        "/**/*.js",
                        "/**/*.css",
                        "/**/*.jpg",
                        "/**/*.png",
                        "/**/*.woff2",
                        "/code/image")
                .permitAll()//以上的請求都不需要認證
                .anyRequest()
                .authenticated()
                .and()
                .csrf().disable();
    }
}
4)用戶名密碼登錄

配置好之後,下面我可以正式開始使用SpringSecurity OAuth配置用戶名和密碼登錄,也就是表單登錄,SpringSecurity默認有Form登錄和Basic登錄,我們已經在SsbResourceServerConfig類的configure方法上面設置了 http.formLogin()也就是表單登錄,也就是這裏的用戶名密碼登錄,默認情況下SpringSecurity已經實現了表單登錄的封裝了,所以我們只要設置成功之後返回的Token就好,我們創建一個繼承SavedRequestAwareAuthenticationSuccessHandler的SsbAuthenticationSuccessHandler類,代碼如下:

@Component("ssbAuthenticationSuccessHandler")
public class SsbAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private ClientDetailsService clientDetailsService;

    @Autowired
    private AuthorizationServerTokenServices authorizationServerTokenServices;
    /*
     * (non-Javadoc)
     *
     * @see org.springframework.security.web.authentication.
     * AuthenticationSuccessHandler#onAuthenticationSuccess(javax.servlet.http.
     * HttpServletRequest, javax.servlet.http.HttpServletResponse,
     * org.springframework.security.core.Authentication)
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        String name = authentication.getName();
//        String password = (String) authentication.getCredentials();
        if (header == null || !header.startsWith("Basic ")) {
            throw new UnapprovedClientAuthenticationException("請求頭中無client信息");
        }

        String[] tokens = extractAndDecodeHeader(header, request);
        assert tokens.length == 2;
        String clientId = tokens[0];
        String clientSecret = tokens[1];

        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);

        if (clientDetails == null) {
            throw new UnapprovedClientAuthenticationException("clientId對應的配置信息不存在:" + clientId);
        } else if (!StringUtils.equals(clientDetails.getClientSecret(), clientSecret)) {
            throw new UnapprovedClientAuthenticationException("clientSecret不匹配:" + clientId);
        }

        TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom");

        OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);

        OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);

        OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(token));
    }

    private String[] extractAndDecodeHeader(String header, HttpServletRequest request) throws IOException {

        byte[] base64Token = header.substring(6).getBytes("UTF-8");
        byte[] decoded;
        try {
            decoded = Base64.decode(base64Token);
        } catch (IllegalArgumentException e) {
            throw new BadCredentialsException("Failed to decode basic authentication token");
        }
        String token = new String(decoded, "UTF-8");
        int delim = token.indexOf(":");
        if (delim == -1) {
            throw new BadCredentialsException("Invalid basic authentication token");
        }
        return new String[] { token.substring(0, delim), token.substring(delim + 1) };
    }
}

這樣就可以成功的返回Token給前端,然後我們必須放開/form/token請求地址,我們已經在SsbResourceServerConfig類的configure方法放行了,並且設置成功處理類ssbAuthenticationSuccessHandler方法,和失敗處理類ssbAuthenticationFailureHandler如下所示:

clipboard.png

下面我們就用PostMan測試下看是否成功,不過在這之前我們還要創建一個基於UserDetailsService的ApiUserDetailsService類,這個類的使用是從數據庫中查詢認證的用戶信息,這裏我們就沒有從數據庫中查詢,但是你要知道這個類是做什麼用的,代碼如下:

@Component
public class ApiUserDetailsService implements UserDetailsService{

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

    @Autowired
    private PasswordEncoder passwordEncoder;

    /*
     * (non-Javadoc)
     *
     * @see org.springframework.security.core.userdetails.UserDetailsService#
     * loadUserByUsername(java.lang.String)
     */
    // 這裏的username 可以是username、mobile、email
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("表單登錄用戶名:" + username);
        return buildUser(username);
    }

    private SocialUser buildUser(String userId) {
        // 根據用戶名查找用戶信息
        //根據查找到的用戶信息判斷用戶是否被凍結
        String password = passwordEncoder.encode("123456");
        logger.info("數據庫密碼是:" + password);
        return new SocialUser(userId, password,
                true, true, true, true,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
    }
}

clipboard.png

clipboard.png

clipboard.png

這樣用戶名密碼登錄就成功了!下面我們來處理手機號驗證碼登錄獲取token。

5)手機號驗證碼登錄

首先要配置redis,我們把驗證碼放到redis裏面(注意,發送驗證碼其實就是往redis裏面保存一條記錄,這個我就不詳細說了),配置如下所示:

spring.redis.host=127.0.0.1
spring.redis.password=zhangwei
spring.redis.port=6379
# 連接超時時間(毫秒)
spring.redis.timeout=30000

設置好之後,我們要創建四個類

1.基於AbstractAuthenticationToken的SmsCodeAuthenticationToken類,存放token用戶信息類
2.基於AbstractAuthenticationProcessingFilter的SmsCodeAuthenticationFilter類,這是個過濾器,把請求的參數如手機號、驗證碼獲取到,並構造Authentication
3.基於AuthenticationProvider的SmsCodeAuthenticationProvider類,這個類就是驗證你手機號和驗證碼是否正確,並返回Authentication
4.基於SecurityConfigurerAdapter的SmsCodeAuthenticationSecurityConfig類,這個類是承上啓下的使用,把上面三個類配置到這裏面並放到資源服務裏面讓它起使用

clipboard.png

clipboard.png

下面我們來一個一個解析這四個類。
(1)、SmsCodeAuthenticationToken類,代碼如下 :

// 用戶基本信息存儲類
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken{

    // 用戶信息全部放在這裏面,如用戶名,手機號,密碼等
    private final Object principal;
    //這裏保存的證書信息,如密碼,驗證碼等
    private Object credentials;

    //構造未認證之前用戶信息
    SmsCodeAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(false);
    }

    //構造已認證用戶信息
    SmsCodeAuthenticationToken(Object principal,
                               Object credentials,
                               Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true); // must use super, as we override
    }

    public Object getCredentials() {
        return this.credentials;
    }

    public Object getPrincipal() {
        return this.principal;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

(2)、SmsCodeAuthenticationFilter類,代碼如下

//短信驗證碼攔截器
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private boolean postOnly = true;

    // 手機號參數變量
    private String mobileParameter = "mobile";
    private String smsCode = "smsCode";

    SmsCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher("/mobile/token", "POST"));
    }

    /**
     * 添加未認證用戶認證信息,然後在provider裏面進行正式認證
     *
     * @param httpServletRequest
     * @param httpServletResponse
     * @return
     * @throws AuthenticationException
     * @throws IOException
     * @throws ServletException
     */
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse)
            throws AuthenticationException, IOException, ServletException {
        if (postOnly && !httpServletRequest.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + httpServletRequest.getMethod());
        }

        String mobile = obtainMobile(httpServletRequest);
        String smsCode = obtainSmsCode(httpServletRequest);
        //todo:驗證短信驗證碼2
        if (mobile == null) {
            mobile = "";
        }

        mobile = mobile.trim();

        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile, smsCode);
        // Allow subclasses to set the "details" property
        setDetails(httpServletRequest, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * 獲取手機號
     */
    private String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    private String obtainSmsCode(HttpServletRequest request) {
        return request.getParameter(smsCode);
    }

    private void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    public void setMobileParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.mobileParameter = usernameParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getMobileParameter() {
        return mobileParameter;
    }
}

(3)、SmsCodeAuthenticationFilter類,代碼如下

//用戶認證所在類
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

   private RedisTemplate<Object, Object> redisTemplate;

    // 注意這裏的userdetailservice ,因爲SmsCodeAuthenticationProvider類沒有@Component
    // 所以這裏不能加@Autowire,只能通過外面設置才行
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 在這裏認證用戶信息
     * @param authentication
     * @return
     * @throws AuthenticationException
     */
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
//        String mobile = (String) authenticationToken.getPrincipal();
        String mobile = authentication.getName();
        String smsCode = (String) authenticationToken.getCredentials();

        //從redis中獲取該手機號的驗證碼
        String smsCodeFromRedis = (String) redisTemplate.opsForValue().get(mobile);
        if(!smsCode.equals(smsCodeFromRedis)){
            throw new InternalAuthenticationServiceException("手機驗證碼不正確");
        }

        UserDetails user = userDetailsService.loadUserByUsername(mobile);
        if (user == null) {
            throw new InternalAuthenticationServiceException("無法獲取用戶信息");
        }

        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,null, user.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;
    }

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

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public RedisTemplate<Object, Object> getRedisTemplate() {
        return redisTemplate;
    }

    public void setRedisTemplate(RedisTemplate<Object, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
}

(4)、SmsCodeAuthenticationSecurityConfig類,代碼如下

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationSuccessHandler ssbAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler ssbAuthenticationFailureHandler;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private RedisTemplate<Object, Object> redisTemplate;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(ssbAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(ssbAuthenticationFailureHandler);

        SmsCodeAuthenticationProvider smsCodeDaoAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeDaoAuthenticationProvider.setUserDetailsService(userDetailsService);
        smsCodeDaoAuthenticationProvider.setRedisTemplate(redisTemplate);
        http.authenticationProvider(smsCodeDaoAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

上面 代碼都有註解我就不詳細講了,好了我們再來測試下看看是否成功:

clipboard.png
好了,手機號驗證碼用戶認證也成功了!

6、郵箱驗證碼登錄

郵箱驗證碼登錄和上面手機號驗證碼登錄差不多,你們自己試着寫一下。

7、將token保存到Redis裏面

這是拓展功能,不需要的同學可以忽略。
我們改造一下SsbAuthorizationServerConfig類,以支持Redis保存token,如下

@Autowired
private TokenStore redisTokenStore;

@Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        //使用Redis作爲Token的存儲
        endpoints
                .tokenStore(redisTokenStore)
                .userDetailsService(userDetailsService);
    }

然後再新建一下RedisTokenStoreConfig類

@Configuration
@ConditionalOnProperty(prefix = "ssb.security.oauth2", name = "storeType", havingValue = "redis")
public class RedisTokenStoreConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore redisTokenStore(){
        return new RedisTokenStore(redisConnectionFactory);
    }

}

在application.properties裏面添加

ssb.security.oauth2.storeType=redis

好了,我們測試下

clipboard.png

clipboard.png

這樣就成功的保存到redis了。

8、使用JWT生成Token

jwt是什麼請自行百度。
首先還是要改造SsbAuthorizationServerConfig類,代碼如下:

@Autowired(required = false)
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Autowired(required = false)
private TokenEnhancer jwtTokenEnhancer;

 @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        //使用Redis作爲Token的存儲
        endpoints
//                .tokenStore(redisTokenStore)
//                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);

        //1、設置token爲jwt形式
        //2、設置jwt 拓展認證信息
        if (jwtAccessTokenConverter != null && jwtTokenEnhancer != null) {
            TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
            List<TokenEnhancer> enhancers = new ArrayList<TokenEnhancer>();
            enhancers.add(jwtTokenEnhancer);
            enhancers.add(jwtAccessTokenConverter);

            enhancerChain.setTokenEnhancers(enhancers);
            endpoints.tokenEnhancer(enhancerChain)
                    .accessTokenConverter(jwtAccessTokenConverter);
        }
    }

然後我們再來創建JwtTokenStoreConfig類代碼如下:

@Configuration
@ConditionalOnProperty(
        prefix = "ssb.security.oauth2",
        name = "storeType",
        havingValue = "jwt",
        matchIfMissing = true)
public class JwtTokenStoreConfig {

    @Value("${ssb.security.jwt.signingKey}")
    private String signingkey;

    @Bean
    public TokenEnhancer jwtTokenEnhancer() {
        return new SsbJwtTokenEnhancer();
    }

    @Bean
    public TokenStore jetTokenStroe() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        //設置默認值
        if(StringUtils.isEmpty(signingkey)){
            signingkey = "awbeci";
        }
        //密鑰,放到配置文件中
        jwtAccessTokenConverter.setSigningKey(signingkey);
        return jwtAccessTokenConverter;
    }
}

再創建一個基於JwtTokenEnhancerHandler的ApiJwtTokenEnhancerHandler類,代碼如下:

/**
 * 拓展jwt token裏面的信息
 */
@Service
public class ApiJwtTokenEnhancerHandler implements JwtTokenEnhancerHandler {

    public HashMap<String, Object> getInfoToToken() {
        HashMap<String, Object> info = new HashMap<String, Object>();
        info.put("author", "張威");
        info.put("company", "awbeci-copy");
        return info;
    }
}

最後不要忘了在application.properties裏面設置一下

ssb.security.oauth2.storeType=jwt
ssb.security.jwt.signingKey=awbeci

好了,我們來測試一下吧

clipboard.png

clipboard.png

4、總結

1)spring-security已經幫我們封裝了用戶名密碼的表單登錄了,我們只要實現手機號驗證碼登錄就好
2)一共6個類,一個資源服務類,一個認證服務類,一個手機驗證碼信息類,一個手機驗證碼攔截器,一個認證手機驗證碼類,一個配置類,就這麼多,其實不難,有時候看網上人家寫的好多,看着都要嚇死。
3)後面有時間寫一下SSO單點登錄的文章

如果你覺得幫助到你了,可以請我喝一杯咖啡^_^

clipboard.png

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