SpringSecurityOauth2系列學習(二):授權服務


系列導航

SpringSecurity系列

SpringSecurityOauth2系列

授權服務

代碼參考

認識了Oauth2之后,接下來我們就要開始正式編碼寫一個demo了

從上一節學習中看來,授權服務起了非常大的重用,我們這里也從搭建一個授權服務開始

在動手之前,咱們來了解一下SpringSecurityOauth2中授權服務的一些概念

概念

授權服務配置

  • 配置一個授權服務,需要考慮 授權類型(GrantType)、不同授權類型為客戶端(Client)提供了不同的獲取令牌(Token)方式,每一個客戶端(Client)都能夠通過明確的配置以及權限來實現不同的授權訪問機制,也就是說如果你提供了一個 client_credentials 授權方式,並不意味着其它客戶端就要采用這種方式來授權
  • 使用@EnableAuthorizationServer來配置授權服務機制,並繼承 AuthorizationServerConfigurerAdapter 該類重寫 configure 方法定義授權服務器策略

配置客戶端詳情(Client Details)

  • ClientDetailsServiceConfigurer 能夠使用內存或 JDBC 方式實現獲取已注冊的客戶端詳情,有幾個重要的屬性:
    • clientId:客戶端標識 ID(賬號)
    • secret:客戶端安全碼(密碼)
    • scope:客戶端訪問范圍,默認為空則擁有全部范圍
    • authorizedGrantTypes:客戶端使用的授權類型,默認為空
    • authorities:客戶端可使用的權限

管理令牌(Managing Token)

​ 讀和寫令牌所用的tokenService不同

  • ResourceServerTokenServices 接口定義了令牌加載、讀取方法
  • AuthorizationServerTokenServices 接口定義了令牌的創建、獲取、刷新方法
  • ConsumerTokenServices 定義了令牌的撤銷方法(刪除)
  • DefaultTokenServices 實現了上述三個接口,它包含了一些令牌業務的實現,如創建令牌、讀取令牌、刷新令牌、獲取客戶端ID。默認的創建一個令牌時,是使用 UUID 隨機值進行填充的。除了持久化令牌是委托一個 TokenStore 接口實現以外,這個類幾乎幫你做了所有事情
  • TokenStore接口負責持久化令牌,也有一些實現:
    • InMemoryTokenStore:默認采用該實現,將令牌信息保存在內存中,易於調試
    • JdbcTokenStore:令牌會被保存近關系型數據庫,可以在不同服務器之間共享令牌
    • JwtTokenStore:使用 JWT 方式保存令牌,它不需要進行存儲,但是它撤銷一個已經授權令牌會非常困難,所以通常用來處理一個生命周期較短的令牌以及撤銷刷新令牌
    • RedisTokenStore:保存在Redis中,這種方式比起JDBC的優點在於redis的性能高,讀取和寫入非常快,缺點就是需要一個多維護一個reids中間件環境。

JWT 令牌(JWT Tokens)

  • 使用 JWT 令牌需要在授權服務中配置一個 tokenEnhancer去簽發JWT令牌,JWT的簽發與驗證 依賴這個類進行編碼以及解碼,因此授權服務需要這個轉換類,並為資源服務器提供密鑰(對稱加密)或者公鑰(非對稱加密)對JWT進行解密
  • Token 令牌默認是有簽名的,並且資源服務器中需要驗證這個簽名,因此需要一個對稱的 Key 值,用來參與簽名計算
  • 這個 Key 值存在於授權服務之中,或者使用非對稱加密算法加密 Token 進行簽名,Public Key 公布在 jwk set這個端點接口中,需要自行實現

配置授權類型(Grant Types)

  • 授權是使用 AuthorizationEndpoint 這個端點來進行控制的,使用 AuthorizationServerEndpointsConfigurer 這個對象實例來進行配置,它可配置以下屬性:
    • authenticationManager:認證管理器,當你選擇了資源所有者密碼(password)授權類型的時候,請設置這個屬性注入一個 AuthenticationManager 對象
    • userDetailsService:可定義自己的 UserDetailsService 接口實現
    • authorizationCodeServices:用來設置收取碼服務的(即 AuthorizationCodeServices 的實例對象),主要用於 authorization_code 授權碼類型模式
    • implicitGrantService:這個屬性用於設置隱式授權模式,用來管理隱式授權模式的狀態
  • tokenGranter:完全自定義授權服務實現(TokenGranter 接口實現),只有當標准的四種授權模式已無法滿足需求時才需要自行去實現。

配置授權端點 URL(Endpoint URLs)

  • AuthorizationServerEndpointsConfigurer 配置對象有一個 pathMapping() 方法用來配置端點的 URL,它有兩個參數:

    • 參數一:端點 URL 默認鏈接
    • 參數二:替代的 URL 鏈接
  • 下面是一些默認的端點 URL:

    • /oauth/authorize:授權端點
    • /oauth/token:令牌端點
    • /oauth/confirm_access:用戶確認授權提交端點
    • /oauth/error:授權服務錯誤信息端點
    • /oauth/check_token:用於資源服務訪問的令牌解析端點

    授權端點的 URL 應該被 Spring Security 保護起來只供授權用戶訪問(加入spring security對這些端點進行身份驗證)

可能這些概念現在還不夠太理解,沒關系,先留個印象,完成這個demo之后,再回顧一下就懂了。

建表

/*
 Navicat Premium Data Transfer

 Source Server         : localhost
 Source Server Type    : MySQL
 Source Server Version : 50730
 Source Host           : localhost:3306
 Source Schema         : demo

 Target Server Type    : MySQL
 Target Server Version : 50730
 File Encoding         : 65001

 Date: 31/08/2021 17:24:14
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for oauth_access_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_access_token`;
CREATE TABLE `oauth_access_token` (
  `token_id` varchar(255) DEFAULT NULL,
  `token` blob,
  `authentication_id` varchar(255) DEFAULT NULL,
  `user_name` varchar(255) DEFAULT NULL,
  `client_id` varchar(255) DEFAULT NULL,
  `authentication` blob,
  `refresh_token` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of oauth_access_token
-- ----------------------------
BEGIN;
COMMIT;

-- ----------------------------
-- Table structure for oauth_approvals
-- ----------------------------
DROP TABLE IF EXISTS `oauth_approvals`;
CREATE TABLE `oauth_approvals` (
  `userid` varchar(255) DEFAULT NULL,
  `clientid` varchar(255) DEFAULT NULL,
  `scope` varchar(255) DEFAULT NULL,
  `status` varchar(10) DEFAULT NULL,
  `expiresat` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `lastmodifiedat` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of oauth_approvals
-- ----------------------------
BEGIN;
COMMIT;

-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(255) NOT NULL COMMENT '客戶端唯一標識',
  `client_name` varchar(255) DEFAULT NULL COMMENT '客戶端名稱',
  `resource_ids` varchar(255) DEFAULT NULL COMMENT '客戶端所能訪問的資源id集合,多個資源時用逗號(,)分隔',
  `client_secret` varchar(255) DEFAULT NULL COMMENT '客戶端密鑰',
  `scope` varchar(255) DEFAULT NULL COMMENT '客戶端申請的權限范圍,可選值包括read,write,trust;若有多個權限范圍用逗號(,)分隔',
  `authorized_grant_types` varchar(255) NOT NULL COMMENT '客戶端支持的授權許可類型(grant_type),可選值包括authorization_code,password,refresh_token,implicit,client_credentials,若支持多個授權許可類型用逗號(,)分隔',
  `web_server_redirect_uri` varchar(255) DEFAULT NULL COMMENT '客戶端重定向URI,當grant_type為authorization_code或implicit時, 在Oauth的流程中會使用並檢查與數據庫內的redirect_uri是否一致',
  `authorities` varchar(255) DEFAULT NULL COMMENT '客戶端所擁有的Spring Security的權限值,可選, 若有多個權限值,用逗號(,)分隔',
  `access_token_validity` int(11) DEFAULT NULL COMMENT '設定客戶端的access_token的有效時間值(單位:秒),若不設定值則使用默認的有效時間值(60 * 60 * 12, 12小時)',
  `refresh_token_validity` int(11) DEFAULT NULL COMMENT '設定客戶端的refresh_token的有效時間值(單位:秒),若不設定值則使用默認的有效時間值(60 * 60 * 24 * 30, 30天)',
  `additional_information` varchar(4096) DEFAULT NULL COMMENT '這是一個預留的字段,在Oauth的流程中沒有實際的使用,可選,但若設置值,必須是JSON格式的數據',
  `autoapprove` varchar(255) DEFAULT 'false' COMMENT '設置用戶是否自動批准授予權限操作, 默認值為 ‘false’, 可選值包括 ‘true’,‘false’, ‘read’,‘write’.',
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='oath2客戶端表';

-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
BEGIN;
INSERT INTO `oauth_client_details` VALUES ('web-client', '前端', NULL, '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 'todo.read,todo.write', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost:8081/login/oauth2/code/web-client-auth-code', NULL, 900, 31536000, '{}', NULL);
INSERT INTO `oauth_client_details` VALUES ('resource-server', '資源服務器', NULL, '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 'todo.read,todo.write', 'authorization_code,password,refresh_token,client_credentials', 'http://localhost:8081/login/oauth2/code/web-client-auth-code', NULL, 900, 31536000, '{}', NULL);
COMMIT;

-- ----------------------------
-- Table structure for oauth_client_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_token`;
CREATE TABLE `oauth_client_token` (
  `token_id` varchar(255) NOT NULL,
  `token` blob,
  `authentication_id` varchar(255) DEFAULT NULL,
  `user_name` varchar(255) DEFAULT NULL,
  `client_id` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`token_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of oauth_client_token
-- ----------------------------
BEGIN;
COMMIT;

-- ----------------------------
-- Table structure for oauth_code
-- ----------------------------
DROP TABLE IF EXISTS `oauth_code`;
CREATE TABLE `oauth_code` (
  `code` varchar(255) DEFAULT NULL,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授權碼Code記錄表';

-- ----------------------------
-- Records of oauth_code
-- ----------------------------
BEGIN;
COMMIT;

-- ----------------------------
-- Table structure for oauth_refresh_token
-- ----------------------------
DROP TABLE IF EXISTS `oauth_refresh_token`;
CREATE TABLE `oauth_refresh_token` (
  `token_id` varchar(255) DEFAULT NULL,
  `token` blob,
  `authentication` blob
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of oauth_refresh_token
-- ----------------------------
BEGIN;
COMMIT;

-- ----------------------------
-- Table structure for ss_authority
-- ----------------------------
DROP TABLE IF EXISTS `ss_authority`;
CREATE TABLE `ss_authority` (
  `id` int(11) NOT NULL COMMENT '主鍵',
  `parent_id` int(11) DEFAULT NULL COMMENT '父權限id',
  `name` varchar(255) NOT NULL COMMENT '權限名稱',
  `desc` varchar(255) DEFAULT NULL COMMENT '權限描述',
  `resource` varchar(255) DEFAULT NULL COMMENT '權限資源,當type為1時有值',
  `type` int(1) NOT NULL COMMENT '權限類型。0:菜單,1:組件',
  `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='權限表';

-- ----------------------------
-- Records of ss_authority
-- ----------------------------
BEGIN;
INSERT INTO `ss_authority` VALUES (1, NULL, '用戶菜單', '用戶菜單', NULL, 0, '2021-08-23 16:15:15');
INSERT INTO `ss_authority` VALUES (101, 1, '菜單1', '菜單1', NULL, 0, '2021-08-23 16:15:36');
INSERT INTO `ss_authority` VALUES (102, 1, '菜單2', '菜單2', NULL, 0, '2021-08-23 16:15:54');
INSERT INTO `ss_authority` VALUES (10101, 101, '問好', '菜單1功能:問好', 'api:hello', 1, '2021-08-23 16:18:01');
INSERT INTO `ss_authority` VALUES (10201, 102, '用戶名', '菜單2功能:輸出用戶名', 'user:name', 1, '2021-08-23 17:02:08');
COMMIT;

-- ----------------------------
-- Table structure for ss_authority_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `ss_authority_role_rel`;
CREATE TABLE `ss_authority_role_rel` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
  `authority_id` int(11) NOT NULL COMMENT '權限id',
  `role_id` int(11) NOT NULL COMMENT '角色id',
  PRIMARY KEY (`id`,`authority_id`,`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 COMMENT='權限—角色關聯表';

-- ----------------------------
-- Records of ss_authority_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `ss_authority_role_rel` VALUES (1, 1, 1);
INSERT INTO `ss_authority_role_rel` VALUES (2, 101, 1);
INSERT INTO `ss_authority_role_rel` VALUES (3, 102, 1);
INSERT INTO `ss_authority_role_rel` VALUES (4, 10101, 1);
INSERT INTO `ss_authority_role_rel` VALUES (5, 10201, 1);
INSERT INTO `ss_authority_role_rel` VALUES (6, 1, 2);
INSERT INTO `ss_authority_role_rel` VALUES (7, 101, 2);
INSERT INTO `ss_authority_role_rel` VALUES (8, 10101, 2);
COMMIT;

-- ----------------------------
-- Table structure for ss_role
-- ----------------------------
DROP TABLE IF EXISTS `ss_role`;
CREATE TABLE `ss_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL COMMENT '角色名',
  `desc` varchar(255) DEFAULT NULL COMMENT '角色描述',
  `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='角色表';

-- ----------------------------
-- Records of ss_role
-- ----------------------------
BEGIN;
INSERT INTO `ss_role` VALUES (1, 'ADMIN', '超級管理員', '2021-08-23 16:08:02');
INSERT INTO `ss_role` VALUES (2, 'USER', '用戶', '2021-08-23 16:08:02');
COMMIT;

-- ----------------------------
-- Table structure for ss_user
-- ----------------------------
DROP TABLE IF EXISTS `ss_user`;
CREATE TABLE `ss_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL COMMENT '用戶名',
  `password` varchar(255) NOT NULL COMMENT '密碼',
  `status` int(4) DEFAULT NULL COMMENT '狀態',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='用戶表';

-- ----------------------------
-- Records of ss_user
-- ----------------------------
BEGIN;
INSERT INTO `ss_user` VALUES (1, 'user', '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 1);
INSERT INTO `ss_user` VALUES (2, 'test', '{bcrypt}$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm', 1);
COMMIT;

-- ----------------------------
-- Table structure for ss_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `ss_user_role_rel`;
CREATE TABLE `ss_user_role_rel` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
  `rid` int(11) NOT NULL COMMENT '角色表id',
  `uid` int(11) NOT NULL COMMENT '用戶表id',
  PRIMARY KEY (`id`,`rid`,`uid`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='用戶-角色關聯表';

-- ----------------------------
-- Records of ss_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `ss_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `ss_user_role_rel` VALUES (2, 2, 1);
INSERT INTO `ss_user_role_rel` VALUES (3, 2, 2);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;

需要關注的是以oauth開頭的表,其是SpringSecurityOauth2相關的表,也是官方的表結構

最重要的是oauth_client_details表,已經有注解了,通過access_token_validityrefresh_token_validity去設定token的過期時間

oauth_access_tokenoauth_refresh_token在token持久化設定為JDBC的形式的時候會使用到,會將生成的access_tokenrefresh_token保存在這兩個表中。如果Token持久化設定為redis或者內存的話,便不需要這兩個表。

接下來我們開始demo的編寫

依賴和配置

新建一個maven項目,引入依賴

<?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">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.3</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>authority-server</artifactId>
    <groupId>com.cupricnitrate</groupId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>

        <security.oauth2.version>2.5.0.RELEASE</security.oauth2.version>
        <security.oauth2.autoconfigure.version>2.3.0.RELEASE</security.oauth2.autoconfigure.version>

        <mybatis.plus.version>3.4.3</mybatis.plus.version>
        <mysql.version>8.0.25</mysql.version>
      
    </properties>

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Spring Security OAuth2 依賴 -->
        <!-- 注意,Spring 已經在開發 Spring Authorization Server,下面三個依賴以后逐漸會棄用 -->
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>${security.oauth2.autoconfigure.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>${security.oauth2.version}</version>
        </dependency>

        <!-- 新版 Resource Server 類庫 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <!-- 下面這兩個依賴已經包含在 spring-boot-starter-oauth2-resource-server 中 -->
        <!-- JWK 依賴 -->
        <!-- <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>${nimbus.jose.jwt.version}</version>
        </dependency> -->
        <!-- JWT 依賴 -->
        <!-- <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-jwt</artifactId>
            <version>${security.jwt.version}</version>
        </dependency> -->

        <!--ORM框架-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis.plus.version}</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>

        <!--token持久化到redis中-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>

</project>

這個demo采用的依賴版本:

  • spring-boot:2.4.3
  • spring-security-oauth2:2.5.0.RELEASE
  • mybatis-plus:3.4.3
  • mysql-connector-java:8.0.25

application.yml

server:
  port: 8080
  servlet:
    context-path: /${spring.application.name}

spring:
  application:
    #服務名稱
    name: authorization-server
  servlet:
    multipart:
      max-file-size: 1024MB
      max-request-size: 1024MB
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    #mysql驅動8.x版本使用com.mysql.cj.jdbc.Driver
    #5.x使用com.mysql.jdbc.Driver
    driver-class-name: com.mysql.cj.jdbc.Driver
    #數據庫地址
    url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
    #數據庫賬號
    username: root
    #數據庫密碼
    password: root
    #hikari連接池
    hikari:
      #2*cpu
      maximum-pool-size: 16
      #cpu
      minimum-idle: 8
      data-source-properties:
        cachePrepStmts: true
        prepStmtCacheSize: 250
        prepStmtCacheSqlLimit: 2048
        useServerPrepStmts: true
  redis:
    port: 6379
    host: localhost
    database: 0

配置好數據庫,redis

jwk set

授權服務器會提供一個接入點或者api。通過這個api可以直接將服務端的JWK的定義直接拿出來

按照慣例,這個接口是在服務端的.well-known/jwks.json接口

其返回結構如下:

{
  "keys": [
    {
      # 用什么算法加密
      "kty": "RSA",
      "e": "AQAB",
      # 公鑰
      "n": "xxxxx"
    }
  ]
}

客戶端請求token,不是在其內部內嵌一個公鑰,而是從服務器申請,得到公鑰和加密方式

生成密鑰對

之前我們使用的是java工具類去生成一個密鑰對,這里我們使用命令行去生成,並對密鑰對設置存儲密碼

$ keytool -genkeypair -alias 別名 -keyalg RSA -keypass 密鑰的密碼 -keystore 密鑰名 -storepass 存儲密碼

比如:

$ keytool -genkeypair -alias oauth-jwt -keyalg RSA -keypass password -keystore jwt.jks -storepass password

執行之后,會出現一個warning:

Warning:
JKS 密鑰庫使用專用格式。建議使用 "keytool -importkeystore -srckeystore jwt.jks -destkeystore jwt.jks -deststoretype pkcs12" 遷移到行業標准格式 PKCS12。

我們按照這個warning進行操作:

$ keytool -importkeystore -srckeystore jwt.jks -destkeystore jwt.jks -deststoretype pkcs12

輸入存儲密碼,得到密鑰jwt.jks

然后將這個密鑰jwt.jks放入授權服務的資源文件夾resources

對jwk set配置安全表達式

創建JwkSetEndpointConfiguration

import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerSecurityConfiguration;

/**
 * Authorization Server 的安全配置
 * 需要配置以下 /.well-known/jwks.json 允許公開訪問
 * 在存在多個 Security 配置的情況下,需要設置不同的順序,@Order 是必須的
 * 這個配置是授權服務器的,所以優先級高
 *
 */
@Order(1)
@Configuration
class JwkSetEndpointConfiguration extends AuthorizationServerSecurityConfiguration {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .requestMatchers(req -> req.mvcMatchers("/.well-known/jwks.json"))
                .authorizeRequests(req -> req.mvcMatchers("/.well-known/jwks.json").permitAll());
    }
}

寫完代碼后,看見AuthorizationServerSecurityConfiguration被划了一道橫線,表示這個類已經被棄用了。

瓦特發!你要拿前朝的劍斬當朝的官?

等一下!這似有原因滴!

因為Spring團隊已經在開發 Spring Authorization Server啦,其旨在代替SpringSecurity中的授權服務器相關依賴。所以SpringSecurity正在慢慢的將授權服務相關的東西丟棄掉。

但是Spring Authorization Server項目現在正在開發中,並且在現在(2021-09-23)的發布版本是0.2,這個版本有點低,雖然Spring團隊表示這個項目已經脫離實驗階段了,但是在生產環境誰敢用這個0.2?出了碧油雞咋辦?

並且除了AuthorizationServerSecurityConfiguration這個類,也沒有其他的選擇,所以還是使用這個類。

創建接入點

公鑰轉換成JSON返回

package com.cupricnitrate.authority.oauth2;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.security.oauth2.provider.endpoint.FrameworkEndpoint;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;
import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;

/**
 * 為了和 Spring Security 5.1 以上版本的 Resource Server 兼容
 * 我們需要讓 Spring Security OAuth2 支持 JWK
 * 這個類是為了暴露 JWK 的接入點
 * `@FrameworkEndpoint` 注解表示其為框架級的接入點,這里可以理解成一個`@RestController`
 *
 * @author 硝酸銅
 * @date 2021/9/23
 */
@FrameworkEndpoint
class JwkSetEndpoint {

    @Resource
    private KeyPair keyPair;

    @GetMapping("/.well-known/jwks.json")
    @ResponseBody
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

Spring Security 5.1 以上版本的 資源服務在配置了jwt為token之后,其默認配置采用非對稱加密解析jwt簽名,但是配置了授權服務的jwk set地址,便會通過這個url區獲取公鑰,使用對稱加密的方式解析jwt簽名

所以我們這里需要將密鑰對配置上,也就是之前使用命令生成的那個密鑰對

keypair配置

這里我們順便把編碼器也配置上,之后會使用

package com.cupricnitrate.authority.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import java.security.KeyPair;
import java.util.HashMap;
import java.util.Map;

/**
 * @author 硝酸銅
 * @date 2021/9/23
 */
@Configuration
public class WebConfig {

    /**
     * 編碼器創建
     * @return PasswordEncoder
     */
    @Bean
    public PasswordEncoder passwordEncoder(){

        //默認編碼算法的Id,新的密碼編碼都會使用這個id對應的編碼器
        String idForEncode = "bcrypt";
        //要支持的多種編碼器
        //舉例:歷史原因,之前用的SHA-1編碼,現在我們希望新的密碼使用bcrypt編碼
        //老用戶使用SHA-1這種老的編碼格式,新用戶使用bcrypt這種編碼格式,登錄過程無縫切換
        Map encoders = new HashMap();
        encoders.put(idForEncode,new BCryptPasswordEncoder());
        //encoders.put("SHA-1",new MessageDigestPasswordEncoder("SHA-1"));

        //(默認編碼器id,編碼器map)
        return new DelegatingPasswordEncoder(idForEncode,encoders);
    }

    @Bean
    public KeyPair keyPair() {
        //獲取資源文件中的密鑰
        ClassPathResource ksFile = new ClassPathResource("jwt.jks");
        //輸入密碼創建KeyStoreKeyFactory
        KeyStoreKeyFactory ksFactory = new KeyStoreKeyFactory(ksFile, "password".toCharArray());
        //通過別名獲取KeyPair
        return ksFactory.getKeyPair("oauth-jwt");
    }
}

測試

啟動項目,請求這個接口

這樣一個jwk set接入點就做好了,接下來我們完成授權服務的主要配置

建立授權服務安全配置

授權服務需要使用@EnableAuthorizationServer來配置授權服務機制,並繼承 AuthorizationServerConfigurerAdapter 該類重寫 configure 方法定義授權服務策略

package com.cupricnitrate.authority.config;

import com.cupricnitrate.authority.service.UserDetailsServiceImpl;
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.crypto.password.PasswordEncoder;
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.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.annotation.Resource;
import javax.sql.DataSource;
import java.security.KeyPair;
import java.util.Collections;

/**
 * `@EnableAuthorizationServer` 注解表示激活授權服務器
 * @author 硝酸銅
 * @date 2021/8/27
 */
@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    /**
     * 密鑰對
     */
    @Resource
    private KeyPair keyPair;

    /**
     * 數據源
     * RedisTokenStore 需要RedisConnectionFactory,其會讀取配置文件spring.redis的配置
     */
    @Resource
    private RedisConnectionFactory connectionFactory;

    /**
     * 數據源
     * 讀取配置文件中spring.datasource的配置
     */
    @Resource
    private DataSource dataSource;

    /**
     * 用戶service
     */
    @Resource
    private UserDetailsServiceImpl userDetailsServiceImpl;

    /**
     * 密碼編碼器
     */
    @Resource
    private PasswordEncoder passwordEncoder;

    @Resource
    private AuthenticationManager authenticationManager;


    /**
     * 配置授權服務器的 token 接入點
     *
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security
                // 與SpringSecurity中的access()方法類似,設置復雜安全表達式
                // 設置可以請求接入點的安全表達式為`permitAll()`
                .tokenKeyAccess("permitAll()")
                // 設置檢查token的安全表達式為`isAuthenticated()`,已認證
                .checkTokenAccess("isAuthenticated()")
                // 允許進行表單認證
                .allowFormAuthenticationForClients()
                // 設置oauth_client_details中的密碼編碼器
                .passwordEncoder(passwordEncoder);
    }

    /**
     * 配置 Jdbc 版本的 JdbcClientDetailsService
     * 也就是讀取oauth_client_details表的信息
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 讀取配置文件中spring.datasource的配置
        clients.jdbc(dataSource);
    }

    /**
     * 配置授權訪問的接入點
     * @param endpoints
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        // endpoints.pathMapping("/oauth/token","/token/login");  設置token生成請求地址
        TokenEnhancerChain chain = new TokenEnhancerChain();
        chain.setTokenEnhancers(Collections.singletonList(accessTokenConverter()));

        endpoints
                // chain和accessTokenConverter()二選一即可
                //.accessTokenConverter(accessTokenConverter())
                .tokenEnhancer(chain)
                // token 持久化
                .tokenStore(tokenStore())
                // 配置認證 manager
                .authenticationManager(authenticationManager)
                // 配置用戶
                .userDetailsService(userDetailsServiceImpl);
    }

    /**
     * 使用jwt生成token
     * 如果需要自定義token或者獲取token接口的返回體,需要實現TokenEnhancer接口的enhance方法,具體可以看一下JwtAccessTokenConverter類
     * @return JwtAccessTokenConverter
     */
    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        //非對稱加密簽名
        converter.setKeyPair(this.keyPair);

        //對稱加密簽名
        //converter.setSigningKey("xxx");
        return converter;
    }

    /**
     * token持久化
     * @return TokenStore
     */
    @Bean
    public TokenStore tokenStore() {
        //將token保存在redis中
        return new RedisTokenStore(connectionFactory);

        //將token保存在內存中
        //return new InMemoryTokenStore();


        //不用管如何進行存儲(內存或磁盤),因為它可以把相關信息數據編碼存放在令牌里。JwtTokenStore 不會保存任何數據
        //return new JwtTokenStore(accessTokenConverter());

        //將token保存在數據庫中
        //return new JdbcTokenStore(dataSource);
    }

}

具體看代碼,注釋已經寫得很詳細了

其中的userDetailsServiceImpl所涉及的model和mapper以及其本身的邏輯,和上一節中學習SpringSecurity的邏輯是一樣的,這里就不多做贅述了。

我們來關注一下tokenStore()這個方法,就是TokenStore,對token的持久化

token持久化

token持久化主要作用於/oauth/token(獲取令牌端點接口)和/oauth/check_token(令牌檢查解析端點接口)。

調用/oauth/token端點接口時,會先通過TokenStore判斷持久化容器中是否有這個token,token過期沒有。

如果有這個token並且這個token也沒有過期,則會直接返回這個token,不會生成新的token。如果TokenStore沒有這個token或者有但是token過期了,則會生成新的token並保存在持久化容器中。

調用/oauth/check_token端點接口的時候,會先通過TokenStore判斷持久化容器中是否有這個token,token過期沒有。

如果TokenStore中有並且沒有過期,則會將token中所包含的荷載信息解析出來。如果TokenStore沒有這個token或者有但是token過期了,則會報錯token無效。

InMemoryTokenStore

這個是OAuth2默認采用的實現方式。在單服務上可以體現出很好特效(即並發量不大,並且它在失敗的時候不會進行備份),大多項目都可以采用此方法。根據名字就知道了,是存儲在內存中,畢竟存在內存,而不是磁盤中,調試簡易。

JdbcTokenStore

這個是基於JDBC的實現,令牌(Access Token)會保存到數據庫。這個方式,可以在多個服務之間實現令牌共享。

需要有oauth_access_token表和oauth_refresh_token

表的結構在上文ddl中

JwtTokenStore

jwt全稱 JSON Web Token。這個實現方式不用管如何進行存儲(內存或磁盤),因為它可以把相關信息數據編碼存放在令牌里。JwtTokenStore 不會保存任何數據,但是它在轉換令牌值以及授權信息方面與 DefaultTokenServices 所扮演的角色是一樣的。

這種方式有個缺陷,授權服務無法主動控制token的失效,只能等token自己失效過期(登出不能主動控制)

RedisTokenStore

由於TokenStore作用就是對於OAuth2令牌持久化接口,而我們在實際開發中,對於內存的使用是慎之又慎,而對於存儲到數據庫也是根據項目需求進行調配。因此就想,可不可以用redis來進行存儲持久化我們的OAuth2令牌。當然是可以的,並且這也是微服務中保存token使用的較多的方案

AuthenticationManager

AuthenticationManager來自於授權服務器的WebSecurityConfigurerAdapter

那么現在問題來了:為什么授權服務器還需要WebSecurityConfigurerAdapter配置呢?不是有AuthorizationServerConfigurerAdapter就可以了嗎?

AuthorizationServerConfigurerAdapter只是授權服務的安全配置,來自於依賴spring-security-oauth2,對oauth2的授權部分做了配置。但是該依賴中也包含spring-security-core、spring-security-web、spring-security-config,也就是說其也受SpringSecurity所保護

通過之前的內容我們知道,引入SpringSecurity依賴后,默認會有一個配置,但是這種默認的配置不滿足我們的需求,所以還是需要繼承WebSecurityConfigurerAdapter做好安全配置

package com.cupricnitrate.authority.config;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;


/**
 * @author 硝酸銅
 * @date 2021/8/30
 */

@Configuration
@EnableWebSecurity(debug = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //授權模式登陸頁面,這里采用默認的登陸頁面
                .formLogin()
                .and()
                .httpBasic()
                .and()
                .csrf(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                // 授權模式這里采用默認的頁面,登陸成功后從session中拿取回調url
                //.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeRequests(req -> req
                        //oauth2 的接口在 /oauth 接口下面,這里配置其不走SpringSecurity的認證流程
                        .antMatchers("/oauth/**").permitAll()
                        .anyRequest().authenticated()
                ) ;
    }

    @Override
    public void configure(WebSecurity web) {
        web
                .ignoring()
                .antMatchers("/error",
                        "/resources/**",
                        "/static/**",
                        "/public/**",
                        "/h2-console/**",
                        "/swagger-ui.html",
                        "/swagger-ui/**",
                        "/v3/api-docs/**",
                        "/v2/api-docs/**",
                        "/doc.html",
                        "/swagger-resources/**")
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

學習了之前SpringSecurity的小伙伴看到這個配置,可能會有所疑惑,為什么這里要使用登陸頁面呢?說好的前后端分離呢?

你且聽我慢慢道來~

這個頁面其實都是為了服務Oauth2中的授權碼模式的,授權碼模式一共就需要兩個頁面,一個是登陸頁面,一個是同意授權的頁面。

為了這兩個頁面搞前后端分離,不僅要重寫授權端點接口(獲取授權碼的端點接口源碼就是按照前后端一體的方式做的,其有一個model入參),項目上線的時候還要為這兩個頁面搭一個ng實在是有點不合適,浪費資源!並且我這里只是做一個demo,就使用默認登陸頁面了。

啟動服務,測試端點

到這里一個授權服務就搭好了,我們啟動項目,測試一下

啟動項目前先確定redis正常並且配置正常

密碼模式

調用接口

http://localhost:8080/authorization-server/oauth/token

grant_type指定為:password

注意,此處需加 Authorization 請求頭,值為 Basic xxx xxx 為 client_id:client_secret 的 base64編碼。

在https://jwt.io/上面解析token:

授權碼模式

還記得授權碼模式需要訪問頁面嗎?需要登陸和同意授權,這也是為什么授權服務器打開了登陸頁面的原因

瀏覽器訪問:

http://localhost:8080/authorization-server/oauth/authorize?response_type=code&client_id=web-client&client_secret=$2a$10$jhS817qUHgOR4uQSoEBRxO58.rZ1dBCmCTjG8PeuQAX4eISf.zowm&redirect_uri=http://localhost:8081/login/oauth2/code/web-client-auth-code&state=123

參數說明:

  • redirect_uri:回調地址,如果數據庫里面配置這個參數,則需要帶上這個參數,並且需要和數據庫中一致
  • state:回調的時候會帶上這個state,自定義,可以對其這個字段進行校驗,判斷其是否來自oauth2接口

SpringSecurity發現我們沒有登陸,重定向到登陸頁面

先進行登陸

登陸后跳轉到oauth2默認的授權頁面

點擊Authorize,進入回調頁面,也就是設置的回調redirect_uri

這里會帶上codestate參數,code就是授權碼,state就是之前請求時帶上的狀態碼,可用判斷是否是Oauth2回調返回。

通過code獲取授權碼,並且code只能使用一次

注意,此處需加 Authorization 請求頭,值為 Basic xxx xxx 為 client_id:client_secret 的 base64編碼。

grant_type值為authorization_code

這里填寫的redirect_uri也需要和數據庫中的一致


免責聲明!

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



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