oauth2使用心得-----基本概念以及認證服務器搭建


應用場景

我們假設你有一個“雲筆記”產品,並提供了“雲筆記服務”和“雲相冊服務”,此時用戶需要在不同的設備(PC、Android、iPhone、TV、Watch)上去訪問這些“資源”(筆記,圖片)

那么用戶如何才能訪問屬於自己的那部分資源呢?此時傳統的做法就是提供自己的賬號和密碼給我們的“雲筆記”,登錄成功后就可以獲取資源了。但這樣的做法會有以下幾個問題:

  • “雲筆記服務”和“雲相冊服務”會分別部署,難道我們要分別登錄嗎?
  • 如果有第三方應用程序想要接入我們的“雲筆記”,難道需要用戶提供賬號和密碼給第三方應用程序,讓他記錄后再訪問我們的資源嗎?
  • 用戶如何限制第三方應用程序在我們“雲筆記”的授權范圍和使用期限?難道把所有資料都永久暴露給它嗎?
  • 如果用戶修改了密碼收回了權限,那么所有第三方應用程序會全部失效。
  • 只要有一個接入的第三方應用程序遭到破解,那么用戶的密碼就會泄露,后果不堪設想。

為了解決如上問題,oAuth 應用而生。

名詞解釋

  • 第三方應用程序(Third-party application): 又稱之為客戶端(client),比如上節中提到的設備(PC、Android、iPhone、TV、Watch),我們會在這些設備中安裝我們自己研發的 APP。又比如我們的產品想要使用 QQ、微信等第三方登錄。對我們的產品來說,QQ、微信登錄是第三方登錄系統。我們又需要第三方登錄系統的資源(頭像、昵稱等)。對於 QQ、微信等系統我們又是第三方應用程序。
  • HTTP 服務提供商(HTTP service): 我們的雲筆記產品以及 QQ、微信等都可以稱之為“服務提供商”。
  • 資源所有者(Resource Owner): 又稱之為用戶(user)。
  • 用戶代理(User Agent): 比如瀏覽器,代替用戶去訪問這些資源。
  • 認證服務器(Authorization server): 即服務提供商專門用來處理認證的服務器,簡單點說就是登錄功能(驗證用戶的賬號密碼是否正確以及分配相應的權限)
  • 資源服務器(Resource server): 即服務提供商存放用戶生成的資源的服務器。它與認證服務器,可以是同一台服務器,也可以是不同的服務器。簡單點說就是資源的訪問入口,比如上節中提到的“雲筆記服務”和“雲相冊服務”都可以稱之為資源服務器。

交互過程

舉個例子來說吧,你使用qq號登錄知乎,肯定不能告訴知乎你的密碼,那么怎么做呢?知乎返回授權頁,用戶授權知乎,然后知乎向qq申請令牌,知乎通過令牌去訪問用戶qq相關的資源,這樣用戶的密碼不會向知乎暴露,知乎也訪問了用戶相關的qq信息。

客戶端授權模式

客戶端必須得到用戶的授權(authorization grant),才能獲得令牌(access token)。oAuth 2.0 定義了四種授權方式。

  • implicit:簡化模式,不推薦使用
  • authorization code:授權碼模式
  • resource owner password credentials:密碼模式
  • client credentials:客戶端模式

1、簡化模式

簡化模式適用於純靜態頁面應用。所謂純靜態頁面應用,也就是應用沒有在服務器上執行代碼的權限(通常是把代碼托管在別人的服務器上),只有前端 JS 代碼的控制權。

這種場景下,應用是沒有持久化存儲的能力的。因此,按照 oAuth2.0 的規定,這種應用是拿不到 Refresh Token 的。其整個授權流程如下:

2、授權碼模式

授權碼模式適用於有自己的服務器的應用,它是一個一次性的臨時憑證,用來換取 access_token 和 refresh_token。認證服務器提供了一個類似這樣的接口: 

https://www.baidu.com/exchange?code=&client_id=&client_secret=

需要傳入 codeclient_id 以及 client_secret。驗證通過后,返回 access_token 和 refresh_token。一旦換取成功,code 立即作廢,不能再使用第二次。流程圖如下:

這個 code 的作用是保護 token 的安全性。上一節說到,簡單模式下,token 是不安全的。這是因為在第 4 步當中直接把 token 返回給應用。而這一步容易被攔截、竊聽。引入了 code 之后,即使攻擊者能夠竊取到 code,但是由於他無法獲得應用保存在服務器的 client_secret,因此也無法通過 code 換取 token。而第 5 步,為什么不容易被攔截、竊聽呢?這是因為,首先,這是一個從服務器到服務器的訪問,黑客比較難捕捉到;其次,這個請求通常要求是 https 的實現。即使能竊聽到數據包也無法解析出內容。

3、密碼模式-----本文后續基於此種方式 

密碼模式中,用戶向客戶端提供自己的用戶名和密碼。客戶端使用這些信息,向 "服務商提供商" 索要授權。在這種模式中,用戶必須把自己的密碼給客戶端,但是客戶端不得儲存密碼。這通常用在用戶對客戶端高度信任的情況下,比如客戶端是操作系統的一部分。

一個典型的例子是同一個企業內部的不同產品要使用本企業的 oAuth2.0 體系。在有些情況下,產品希望能夠定制化授權頁面。由於是同個企業,不需要向用戶展示“xxx將獲取以下權限”等字樣並詢問用戶的授權意向,而只需進行用戶的身份認證即可。這個時候,由具體的產品團隊開發定制化的授權界面,接收用戶輸入賬號密碼,並直接傳遞給鑒權服務器進行授權即可。

4、客戶端模式

如果信任關系再進一步,或者調用者是一個后端的模塊,沒有用戶界面的時候,可以使用客戶端模式。鑒權服務器直接對客戶端進行身份驗證,驗證通過后,返回 token。

代碼模塊

表結構 

oauth_client_details-----客戶端相關數據

CREATE TABLE `oauth_client_details` (
  `client_id` varchar(128) NOT NULL,
  `resource_ids` varchar(256) DEFAULT NULL,
  `client_secret` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `authorized_grant_types` varchar(256) DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) DEFAULT NULL,
  `authorities` varchar(256) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

client_secret:一定要為BycrPassWord后的串,因為oauth2會拿明文密碼通過bycr加密后與數據庫中數據進行比對。

authorized_grant_types:授權方式,本文以password為例

access_token_validity:token有效期

后台代碼

1、pom.xml 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.ty</groupId>
    <artifactId>auth</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>auth</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

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

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

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

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.1.3.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.16</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

2、application.yml

spring:
  application:
    name: auth-server
  security:
    user:
      # 賬號
      name: taoyong
      # 密碼
      password: 123456
  datasource:
    url: jdbc:mysql://localhost:3306/mysql?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
    driverClassName: com.mysql.jdbc.Driver
    username: alimayun
    password: ty123456
  redis:
    host: 127.0.0.1
    port: 6379
    password:
server:
  port: 8080

3、AuthorizationServerConfiguration

package com.ty.auth.config.auth;

import com.ty.auth.exception.handler.CustomWebResponseExceptionTranslator;
import com.ty.auth.store.CustomRedisToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.sql.DataSource;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    public DataSource dataSource;

    //使用password模式必須要此bean
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        // 基於redis實現,令牌保存到redis,並且可以實現redis刷新的功能
        return new CustomRedisToken(redisConnectionFactory, jdbcClientDetails());
    }

    @Bean
    public ClientDetailsService jdbcClientDetails() {
        // 基於 JDBC 實現,需要事先在數據庫配置客戶端信息
        return new JdbcClientDetailsService(dataSource);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 設置令牌
        endpoints.tokenStore(tokenStore());
        endpoints.authenticationManager(authenticationManager);
        endpoints.exceptionTranslator(new CustomWebResponseExceptionTranslator());
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 讀取客戶端配置
        clients.withClientDetails(jdbcClientDetails());
    }
}

4、WebSecurityConfiguration

package com.ty.auth.config.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 設置默認的加密方式
        return new BCryptPasswordEncoder();
    }

    //password模式必須需要
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {

        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //這里可以在數據庫中做。主要就是實現UserDetailsService接口,自定義loadUserByUsername方法
        //auth.userDetailsService(xxx)
        auth.inMemoryAuthentication()
                // 在內存中創建用戶並為密碼加密
                .withUser("alimayun").password(passwordEncoder().encode("123456")).roles("USER")
                .and()
                .withUser("ty").password(passwordEncoder().encode("123456")).roles("ADMIN");

    }
}

5、ResourceServerConfigurer

package com.ty.auth.config.resource;

import com.ty.auth.exception.handler.CustomAccessDeniedHandler;
import com.ty.auth.exception.handler.MyAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;

//為了方便,直接把認證服務器也當做是一個資源服務器
@Configuration
@EnableResourceServer
public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {

    @Autowired
    private TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenStore(tokenStore).authenticationEntryPoint(new MyAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());
    }
}

6、異常類

CustomAccessDeniedHandler

package com.ty.auth.exception.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Service;


import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Autowired
    private ObjectMapper objectMapper;

    //權限不足異常處理類
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        Map<String, Object> map = new HashMap<>();
        map.put("resultCode", "400");
        map.put("resultMsg", accessDeniedException.getMessage());
        map.put("path", request.getServletPath());
        map.put("timestamp", String.valueOf(new Date().getTime()));
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(objectMapper.writeValueAsString(map));
    }
}

MyAuthenticationEntryPoint

package com.ty.auth.exception.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Service;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    //認證無效,例如token無效等等
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        Map<String, Object> map = new HashMap<>();
        //通過自定義異常可以按照自己的意願去返回這些異常信息,因為大部分企業級應用都是前后分離,對前端友好很重要!
        map.put("resultCode", "401");
        map.put("resultMsg", authException.getMessage());
        map.put("path", request.getServletPath());
        map.put("timestamp", String.valueOf(new Date().getTime()));
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        try {
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), map);
        } catch (Exception e) {
        throw new ServletException();
        }
    }
}

CustomWebResponseExceptionTranslator

package com.ty.auth.exception.handler;

import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.stereotype.Service;

import javax.xml.transform.Result;
import java.util.HashMap;
import java.util.Map;

//這是獲取token階段出現異常部分
public class CustomWebResponseExceptionTranslator implements WebResponseExceptionTranslator {
    public ResponseEntity translate(Exception e) throws Exception {
        if (e instanceof InternalAuthenticationServiceException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "用戶不存在");
            return ResponseEntity.ok(result);
        }

        if (e instanceof InvalidGrantException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "密碼錯誤");
            return ResponseEntity.ok(result);
        }

        if (e instanceof InvalidTokenException) {
            Map<String, Object> result = new HashMap<>();
            result.put("resultCode", "401");
            result.put("resultMsg", "token未識別");
            return ResponseEntity.ok(result);
        }
        throw e;
    }

}

7、CustomRedisToken

package com.ty.auth.store;

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import java.util.Date;

public class CustomRedisToken extends RedisTokenStore {
    private ClientDetailsService clientDetailsService;

    public CustomRedisToken(RedisConnectionFactory connectionFactory, ClientDetailsService clientDetailsService) {
        super(connectionFactory);
        this.clientDetailsService = clientDetailsService;
    }

    //為什么需要刷新token的時間,比如默認1個小時,客戶一直在操作,到了1個小時,讓其登錄,這種體驗很差,應該是客戶啥時候不請求服務器了,隔多長時間
    //認為其token失效
    // 其實這塊可以看下源碼,在客戶端請求過來的時候,首先到達的是org.springframework.security.oauth2.provider.authentication.
    // OAuth2AuthenticationProcessingFilter。然后在請求校驗完token有效之后,以當前時間刷新token,具體時間配置在數據庫中~~~
    @Override
    public OAuth2Authentication readAuthentication(OAuth2AccessToken token) {
        OAuth2Authentication result = readAuthentication(token.getValue());
        if (result != null) {
            // 如果token沒有失效  更新AccessToken過期時間
            DefaultOAuth2AccessToken oAuth2AccessToken = (DefaultOAuth2AccessToken) token;

            //重新設置過期時間
            int validitySeconds = getAccessTokenValiditySeconds(result.getOAuth2Request());
            if (validitySeconds > 0) {
                oAuth2AccessToken.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
            }

            //將重新設置過的過期時間重新存入redis, 此時會覆蓋redis中原本的過期時間
            storeAccessToken(token, result);
        }
        return result;
    }

    protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) {
        if (clientDetailsService != null) {
            ClientDetails client = clientDetailsService.loadClientByClientId(clientAuth.getClientId());
            Integer validity = client.getAccessTokenValiditySeconds();
            if (validity != null) {
                return validity;
            }
        }
        // default 12 hours.
        int accessTokenValiditySeconds = 60 * 60 * 12;
        return accessTokenValiditySeconds;
    }
}

測試

首先編寫一個測試controller

package com.ty.auth.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @PostMapping("/hello")
    public String hello() {
        return "hello";
    }
}

1、打開postman,直接訪問

提示401,沒有認證

2、請求token

點擊preview request,變成下面這樣:

 

3、拿着token值訪問/hello

這就是一個簡單的認證過程。token我設置默認是1800s過期,隨着我不斷請求,token有效期也會自動順延

1733秒過期,過一會兒我再訪問/hello,刷新token

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM