ReLive27/spring-security-oauth2-sample

psersistence-client相关问题

Closed this issue · 3 comments

  1. JdbcClientRegistrationRepository中有save接口, 这个可以使用吗? 如何使用?
  2. 如果向数据库中insert一个oauth2_client_registered, persistence-client中会动态加载这个oauth2_client_registered吗? 如果可以, 是否必须调用JdbcClientRegistrationRepository的save接口, 而不是直接去数据库添加. 因为persistence-client可能无法感知到数据库更新
  3. 我在Spring-Authorization-server相关文档中看到动态注册client的文章, 我看到其中说必须有.scope("client.create")权限. 你这里没有, 是否就代表上述的save接口调用了也没有权限?
  4. 我希望作者可以回答一下, 即使不提供代码也行. 另外我希望作者多写一下注释. 你的文章确实精彩,但是太短了,而且更新代码的同时加注释也方便. 可以直接用中文, 你可以看到spring官方issue中中文与英文间的交流. 只要表达详细,就算用二进制也能看懂

在oauth2-client-registration中看到了相关内容. 抱歉提问前没看完整个项目.
你的文章好像并没有推荐阅读顺序, 我觉得加上比较好

非常感谢您的建议!我会采纳您的建议完善工程。


下面是我做的一些笔记. 代码也有部分注释. 搞了5/6天, 感觉很难, 没有任何教程, 纯看代码摸索.
每个子工程的功能我都总结了下, 接下来我不打算详细研究了. 因为自己在开发一个网页评论"插件", 想法比较多. 想把登录系统做好一些. 我打算这样设计:

  1. 评论模块没用常见的登录系统是因为这个模块是插入到任意网站的某个网页中(可以看作是插件), 登录的信息保存到浏览器如localstorage很危险. 比如之前某个github.io博客中插入了评论模块,登录信息在localstorage. 下次浏览某个没有评论模块的github.io博客时,会由于同源策略被这个博客获取到localstorage内容. 所以登录一定是会话级别的(cookie), 但是我们又想要让用户快速进入登录状态从而拿着用户名来发表评论. 所以评论模块一定要引入第三方授权的方式快速登录. --后续研究持久化access_token发现频繁获取token会占用较大cpu, 所以还得是localstorage

    另外使用我的评论插件时,我想要通知用户的回复及点赞信息,所以一定要与showsome网站建立联系. 所以这就限制了用户必须有showsome网站账号. 当然showsome网站也会采用第三方登录, 这样用户在showsome有了账号后就可以用其他平台快速登录评论模块.

  2. showsome网站采用第三方登录后, 获取第三方的/userinfo后还需要让用户填写昵称(提供第三方昵称作为候选),是否接收邮件等信息才能创建showsome网站的账号.

  3. 发表评论api放在resource-server中, 前端携带access_token来访问api. 采用独立server是因为后续可能还会有其他模块. 这样给如评论模块授予不同的scope, 再采用resource-server非常合适做到资源保护.

  4. access_token在用户登录成功后就返回给他保存到cookie(关闭网页后就删除), 否则就通过评论模块的后端服务器拿着token去访问resource-server发表评论. 至于access_token时效就1year, 这样在浏览网页时就不用频繁续费了. 评论模块后端也就是oauth2的client端需要持久化authorized_client, 这样就可以直接返回上一次用户申请的token,不用反复授权

    1. 在/oauth2/consent接口中找到authorized_client表查询出已经授予的cope,返回到页面上时就禁止取消勾选这些已经授予的scope, 只能新添scope. 如果已经把client端可以申请的scope全申请了就直接跳过consent进入之后流程到发放token的流程. -- 这个功能使用添加ConsentService后自动实现了.
    2. 如果要实现用户像GitHub等平台一样在showsome网站管理给哪些网站授予了什么权限. 则可能还需要server端的authentication表, showsome网站中提供删除/update持久化authentication表记录的接口.
      • 如果未来showsome网站有其他网站来关联账号, 可以让showsome的授权服务器授权成功时将用户及client_id记录到数据库形成authorized_client表中.
  • 以下是我整理的笔记, 如果你需要可以看看. 应该有不少错误理解, 但我觉得整理的思路还是挺好的.
  • 另外笔记是半成品, 我刚分析整理完你的代码. 我已经将client端的client_registered、authorized_client表+server端的consent表加入到了oidc工程中, oidc的client和server端都实现前后端分离. server端也改造为jwt维护用户登录状态.
  • 接下来打算开始写自己的项目代码了. 你的gateway工程我觉得非常完美但是我没精力去研究了. 另外昨天试着搞动态注册client端的那个工程(oauth2-client-registration), 改着改着出现bug了. 我也不想继续了, 太累了. 然后其他redis,device啥的我就没研究, 主要研究核心功能了

单点登录

概念

当一个用户会使用到多个后端服务器时, 每个服务器都要验证用户身份. 此时就属于单点登录.

实现方式

授权服务器+session

授权服务器给用户提供sessionId, 用户将sessionId提交给其他资源服务器, 资源服务器拿着sessionId来授权服务器索要session中保存的用户身份信息.

  • 变形: 授权服务器控制session的创建分发,并将session保存到redis等数据库中. 资源服务器去redis中获取session保存的用户信息. 当然授权服务器也要及时删除"禁用用户"的session

授权服务器+token

授权服务器给用户提供token(比如jwt), 用户基本信息(角色及用户id等非私密信息)保存在jwt的payload中, 用户拿着jwt去访问资源服务器. 资源服务器自己判断jwt是否合法,合法后就采用payload作为用户信息

用户控制

  1. 通过session实现的单点登录方便实现用户控制

  2. 单token实现的单点登录, 需要每个资源服务器维护黑名单

  3. 双token实现单点登录: client端定时去给用户续费access_token, 如果用户被禁止则不续费.

server前后端分离

  1. 全局跨域配置无法影响到oauth2的接口,只能使用代理

  2. security及oauth2的接口是自动放行的,不用配置.

  3. 授权方用户向授权方服务器发起/oauth2/authorization/messaging-client-oidc, 授权方服务器上默认处理方式是重定向为get方法的authorize请求,请求路径是对用的server的authorization-uri(以下称为authorize(get))到授权服务器,并携带授权方服务器配置的所有sopes等信息.

  4. authorize(get)请求进入授权服务器,需要权限,如果没有权限就要去登录页面.之前security默认会将没有权限的转发到/login. 我们自定义AuthorizationHandler后将所有没有权限的仅返回status code. 所以授权服务器前端要拦截authorize请求, 记录这个请求的完整路径, 先让用户登录, 登录完成再发送刚才的authorize(get)请求.否则这个请求直接发到后端就拒绝了

  5. authorize(get)请求顺利进入授权服务器, 默认会分析当前申请的用户基本信息并渲染到浏览器中. 我们自定义consent路径后, 请求重定向到这个路径上, 原本authorize(get)路径的参数都会带上.

    1. 自定义consent路径后, 可以自己决定consent路径返回的资源类型. 比如直接返回一个html页面源码. 我们选择返回元数据, 所以需要有一个页面渲染这些数据. 也不能重定向到authorize(get)请求, 而是在ajax中发送authorize(get)也即consent路径的请求来获取数据并渲染
    2. 默认处理逻辑还会判断之前授予了哪些scope,如果所有能授予的scope都授予了,就会直接跳过这一步以及下一步进入回调.
  6. consent页面渲染完成用户勾选授权的scopes,发送authorize(post)请求到授权服务器, 当用户身份验证(授权服务器上的身份)且同意授权时, 就进行authenticate操作(OAuth2AuthorizationConsentAuthenticationProvider类提供). 成功后就执行.authorizationResponseHandler(new OAuth2AuthorizationAuthenticationSuccessHandler())中定义的操作.

    1. 作为前后端分离项目, 授权码模式下授权服务器后端理论上不主动调用client端的回调接口来传递令牌, 令牌先通过重定向传到client的前端页面, client端前端页面将令牌传给client服务器, client服务器端拿着令牌到授权服务器主动获取access_token.
    2. 当然也可以直接由授权服务器重定向到client的服务器端的回调接口, 因为这两种方式请求方法都是get,令牌都是放在请求路径上, 完全一摸一样.
    3. 这两种方式,主要是看redirectUri如何配置,如果是后端服务器的回调接口则属于第二种. 另外即使改动redirectUri也不会改变client服务器上默认配置的回调接口路径.
  7. 令牌传给服务器底层默认配置的回调接口, 该接口会拿着令牌+client-secret去请求token及userinfo等封装成OAuth2AuthenticationToken(即Authentication). 然后就会触发client端的autho2LoginSuccess, 此时client端就可以在触发的autho2LoginSuccess中定义如何与用户保持会话.

不论是授权方还是授权服务器中的配置, 域名和ip不能等效,比如issuer-uriserver和client必须保持一摸一样

浏览器打开时都用127.0.0.1打开, 配置中也用127.0.0.1 .用localhost打开出bug. ,OAuth RFC 建议不要使用 localhost,而是使用环回文本 127.0.0.1 或 IPv6 ::1

通过对称加密生成的jwt, 如果密钥不用base64编码, 可能出现两个相似密钥验证同一个jwt结果都是validate的情况:

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;

import java.security.SecureRandom;
import java.util.Base64;

/**
 * @author Jain Nieh
 * Environment AppleTree
 * Date 2024/7/8 23:24
 * Description
 */
public class JwtTest {
    public static void main(String[] args) {
        generateSecret();
    }

    /**
     * yourSecretKey和yourSecretKey2验证下面的jwt都为validate
     */
    public static void jwtParser() {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjbGllbnQxMTEyMSIsImF1dGgiOlsicmVhZCIsIndyaXRlIl0sImlhdCI6MTcyMDQ1MDc5MywiZXhwIjoxNzIwNDU0MzkzfQ.HaJGE5PN9TroRl_b95PXSB-UtrgdHASqhKizwynTw84"; // 需要验证的 JWT
        final String jwtSecret = "yourSecretKey";
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            // 验证成功
            System.out.println("JWT is valid: " + claims);
        } catch (Exception e) {
            // 捕获其他异常
            System.out.println("Invalid JWT token: " + e.getMessage());
        }
    }

    /**
     * 生成强随机密钥
     */
    public static void generateSecret() {
        SecureRandom secureRandom = new SecureRandom();
        byte[] secretBytes = new byte[32]; // 256-bit key
        secureRandom.nextBytes(secretBytes);
        String jwtSecret = Base64.getEncoder().encodeToString(secretBytes);

        System.out.println("Generated JWT Secret: " + jwtSecret);
    }
}

bug

在测试server和client时, 将同一个前端项目复制了一份. 启动两个前端项目,ip相同但是端口差1. 在进行登录测试时,两个前端应用都需要发送各自的jwt(在cookie中)到各自后端服务器检查是否有效而判断是否要重新登录,如果jwt无效就删除前端应用中的jwt重新登录获取jwt.

根据同源策略, 这两个前端应用各自操作的cookie不能互相影响. 但是实际上会出现删除/获取/修改对方cookie的情形.

发现bug采用在有嫌疑的未知加log输出, 并查看浏览器console面板中日志输入的具体位置和顺序.

解决方案, 猜测测试环境下对于同源策略不是很严格, 且使用localhost或127.0.0.1会影响浏览器维护cookie的机制. 但是更改ip或域名不方便调试,所以就更换cookie名称.

优化

  1. 将项目完善: serer既可以作为server也能作为client去使用第三方授权. client可以前后端分离且使用jwt维护会话

前后端分离

client端自己登录自己账号:

Authentication Details:
Principal: org.springframework.security.core.userdetails.User [Username=admin33, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_CLIENT_AUTHORITY]]
Authorities: [ROLE_CLIENT_AUTHORITY]
Credentials: null
Authenticated: true
Details: WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null]

client申请授权后的Authentication:

Authentication Details:
Principal: org.springframework.security.core.userdetails.User [Username=admin32, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_CLIENT_AUTHORITY]]
Authorities: [ROLE_CLIENT_AUTHORITY]
Credentials: null
Authenticated: true
Details: WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=4E20E93CF1EEA0EC9854D0C6D3D61C35]

授权后没有role

oidc中

client申请授权后:

Authentication Details:
Principal: Name: [server1], Granted Authorities: [[ROLE_OPERATION]], User Attributes: [{sub=server1, zoneinfo=China/Beijing, birthdate=2022-05-24, role=[ROLE_ADMIN], gender=female, iss=http://127.0.0.1:8080, preferred_username=server1, locale=zh-cn, sid=Kdt-WpdTc9cAPlf1Y_7JeqwF_KLH3oHQtrsaCIqYw6s, updated_at=2024-07-09, azp=relive-client, auth_time=2024-07-09T09:22:29Z, nickname=User, exp=2024-07-09T09:53:46Z, iat=2024-07-09T09:23:46Z, email=server1@163.com, jti=0de3a291-590c-457f-b76d-01d9d9b2dc1c, website=http://127.0.0.1:8080/, email_verified=true, profile=http://127.0.0.1:8080/server1, given_name=First, middle_name=Middle, nonce=scdYR3gE9c1VCAPoqn7PQ98Rwwy1_i0-OX4bdyW6buY, picture=http://127.0.0.1:8080/server1.jpg, aud=[relive-client], name=First Last, family_name=Last}]
Authorities: [ROLE_OPERATION]
Credentials: 
Authenticated: true
Details: WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=8B2AC6A70A97B382E9AD63A151D9D6C1]

authorize-server配置

参考项目注解

  • 双横线划分上半部分是不怎么应用在项目中的功能; 下面的是可以应用在项目中的功能,并按照基础到复杂的顺序排列
  • 加了*的就是主要添加注释的工程.其中resource-server的配置在gateway工程

device-authorization-flow

client端封装到应用程序中的,由用户自己发起. 没有回调接口,client-secret, 使用device-code去申请access_token. 暂时不细究

oauth2-client-model

客户端凭证模式授权方案的实现. 暂时不细究

oauth2-opaque-token

  • 采用opaque令牌类型, server端和resource端需要配置. 只有这个工程使用opaque,其他都是jwt

oauth2-jwk-redis

OAuth2授权服务实现简单的密钥轮换及配置资源服务器JWK缓存

-- 就是管理管理公私密钥的. 推荐2年一换,所以普通项目可以不加这个功能

oauth2-jwk-consul-config

oauth2-jwk-redis实现的功能相同,都是更换公私密钥. 这里是通过Consul实现

oauth2-introspection-with-jwt

access_token使用opaque-token和jwt-token组合的方式. 暂时不细究

oauth2-pkce

算是对授权码模式的增强. 下面笔记中记了,但是没有搞懂应用场景. 暂时不细究

oauth2-token-access-restrictions

通过redis限制access_token获取频率. 看情况应用到项目中



oauth2-login*

  • 搭建基本的jwt授权服务和客户端服务
  • 提供两种访问令牌(jwt)非对称加密算法
  • server端发送jwt附带role字段(内容是用户在server端的authority)
  • server端自定义/userinfo接口; client端自定义如何解析/userinfo接口用户信息
  • 配置的Mapper可以在创建了Authentication后修改其authority字段内容

下面这俩需要配置的挺多,也没看懂配置原理. 所以放在靠后位置

outh2-custom-consent-authorizationserver

server端是单体项目自定义consent页面

outh2-custom-consent-vue*

server端采用前后端分离,并自定义consent页面;

实现server端jwt方式维护用户登录状态


oidc-login*

  • 基于oauth2-persistence-client让client从数据库中加载
  • 实现oidc, 持久化oidc相关对象, 可以将server端的用户role映射到client端的role上
  • client端解析server端发送携带role的jwt,取出role作为authority创建Authentication对象, 也就不用再用Mapper了

gateway-oauth2-login*

  1. spring-gateway作为client的后端服务器
  2. 这个工程设计了详细的RBAC0用户权限模型, 用户管理既有server端也有client端. 且配置了数据库持久化.
  3. resource-server中配置如何解析jwt中的字段作为访问用户的authority. 并在资源requestMatches()中根据authority拦截资源访问
  4. gateway与用户采用session维持登录状态. 使用到了spring-session-data-redis自动保存session(包含用户信息和令牌)

oauth2-jwt*

  1. server端持久化注册进来的client
  2. server端持久化已授权的详细信息如scopes

oauth2-persistence-client*

从数据库加载server端已经注册的client. 但是不能向server发起注册clint的请求

oauth2-client-registration*

  1. oauth2-jwtoauth2-persistence-client基础上实现: client端动态client注册到server. client记录用户授权后的access_token等信息.

    • config中缺少了一些组件,导致authorization表没有添加数据. 可以参考oauth2-jwtoauth2-persistence-client补充上
  2. server端要默认注册一个具有create权限的client端, 多个client端所在的服务器启动后也会将这个client加载进来

  3. 这个工程修改崩溃了. 使用授权登录没有跳转到consent界面,而是直接进入提交post的authorize请求,然后回调. 暂时不搞动态注册client


oidc-front-allJwt*

  • 我基于oidc的改造
  • client和server都是前后端分离
  • client和server采用jwt维持用户登录状态

框架

重要组件

OidcUserInfoService

定义/userinfo接口返回内容.

常见对象含义

authentication

当成功登录(不论是否是授权)后就会创建这个对象,包含一系列信息. 创建时机在验证身份的filter成功时, 就是UsernamePasswordFilter中Set SecurityContextHolder时.

当使用第三方授权时, 创建的也是这个对象. client会自动发起/userinfo请求将第三方提供的用户信息封装到authenticaton中; 当使用账号密码等登录时就是对应filter中自己构建这个对象

这个authentication对象都是在XxxUserServer.loadUser()中创建的.

这个对象在任何filter之后的函数中都可以尝试获取,特别是controller中可以直接注入HandlerMethod中

authority

是authentication对象下的属性, 表示该登录用户在当前系统上的权限. 也是在filter中加入的. 前面工程中提到过可以通过Mapper改变authority值

scope和role

scope只会包含在access_token以及oauth2_authorization和oauth2_authorized_client表中.

role是保存在数据库中的, 创建Authentication对象时会转换为authority对象,具体怎么转就看每个role对应什么权限了.

框架中数据库表

oauth2_client_registered: client端要去注册的client详细信息

oauth2_registered_client: server端记录注册进来的client信息(较少)

oauth2_authorized_client: client端某用户授权成功后的信息,包括scopes等(较少)

oauth2_authorization: server端授权成功后的对象Oauth2Authorization,包含授权一切信息

oauth2_authorization_consent: server端记录用户在consent页面选择scopes, 这个表数据也都在oauth2_authorization表中, 单独使用这个表是因为这个是一个组合索引,查询非常快.

user表及mapping表: 可以出现在server端和client端. server端参考gateway-oauth2-login工程. clinet端参考oidc-login工程