4 OAuth配置
Pan Qiancheng edited this page 2025-12-24 17:30:46 +08:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

OAuth

概述

实现了完整的 OAuth 2.0 授权框架,支持双重角色:

  • OAuth 提供方Provider:系统作为授权服务器,为第三方应用提供用户授权
  • OAuth 应用方Application:系统作为客户端,接入第三方 OAuth 提供商(如 GitHub、Gitea 等)

主要特性

  • 完整的 OAuth 2.0 Authorization Code Flow 实现
  • 符合 RFC 6749 标准规范
  • 支持 Access Token 和 Refresh Token
  • Token 撤销机制RFC 7009
  • 用户授权管理
  • 可扩展的第三方平台接入
  • 完善的安全校验机制

在项目中接入OAuth

新增OAuth需要的namespace

  • 在web下新增namespaceoauth
  • 文件列表如下:

---web/src/app/namespaces
      -->-oauth
               -->------index.json
               -->------index.module.less
               -->------index.tsx

  • index.json:

{
    "path": "/oauth",
    "first": "/common/notAuthorized",
    "notFound": "/common/notFound"
}

  • index.module.less



.mixPanel {
    height: 100% !important;
    background: var(--oak-bg-color-page) !important;
    min-height: unset !important;
}

.mixMain {
    background: unset !important;
    flex-direction: row !important;
    flex: 1 !important;
}

.mixLayout {
    overflow: auto !important;
    min-height: unset !important;
    background: unset !important;
}

.mixContent {
    background: unset !important;
    min-height: unset !important;
    min-width: 792px;
}

.mixContent_xs {
    background: unset !important;
    min-height: unset !important;
    min-width: unset;
}

.login {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}

.loadingBox {
    height: 100%;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    flex: 1;
}

  • index.tsx

import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';

import Styles from './index.module.less';
import useFeatures from '@project/hooks/useFeatures';
import ErrorPage from '@project/components/common/errorPage';
import { ECode } from '@project/types/ErrorPage';
import Spin from '@project/components/common/spin';

import {
    OakTokenExpiredException,
    OakUserInfoLoadingException,
} from 'oak-general-business';

import { OakUnloggedInException } from 'oak-domain/lib/types/Exception';

type Status = 'waiting' | 'success' | 'fail';

function Frontend(props: { namespace: string }) {
    const { namespace } = props;
    const features = useFeatures();
    features.navigator.setNamespace(namespace);

    const [status, setStatus] = useState<Status>('waiting');
    // const [needSet, setNeedSet] = useState(false);

    const init = async () => {
        try {
            const id = features.token.getUserId(true);
            if (id) {
                const userInfo = features.token.getUserInfo();
                const hasLoginName = userInfo?.loginName$user?.length! > 0;
                const hasPassword = userInfo?.hasPassword;

                if (!hasLoginName || !hasPassword) {
                    setStatus('success');
                    return
                }
            }
            setStatus('success');

        } catch (err) {
            if (err instanceof OakUserInfoLoadingException) {
                setStatus('waiting');
                return;
            }
            if (
                err instanceof OakUnloggedInException ||
                err instanceof OakTokenExpiredException
            ) {
                setStatus('success');
                return;
            }
            setStatus('fail');
        }
    };

    useEffect(() => {
        init();
        const unsubscribe = features.token.subscribe(() => {
            init();
        });
        return () => {
            unsubscribe();
        };
    }, []);

    if (status === 'waiting') {
        return (
            <div className={Styles.loadingBox}>
                <Spin tip="Loading" />
            </div>
        );
    }

    if (status === 'fail') {
        return (
            <>
                <ErrorPage code={ECode.error} title='页面加载错误' desc={'抱歉,页面加载出现错误!请稍后再试。'}>
                </ErrorPage>
            </>
        );
    }

    return (
        <Outlet />
    )
}

export default Frontend;

在pages下新增oauth需要的页面

- pages/oauth (这里的oauth对应上面json中的path)
               -> authorize (认证页面组件,作为认证服务时使用,下方有示例)
               -> callback (回调页面组件,作为认证客户端时,这个页面作为回调页面,下方有示例)
               -> common (普通页面组件例如notFoundnotAuthorized可自定义编写上方json内使用到这里的页面)

组件示例:

oauth/authorize:

  • locales/zh_CN.json
{
    "oauth":{
        "authorize": {
            "loginRequired": "请先登录您的账号"
        }
    }
}
  • index.ts
export default OakComponent({
    // Virtual Component
    isList: false,
    filters: [],
    properties: {},
    data: {
        userId: '',
        dispose: null as (() => void) | null,
    },
    methods: {
        async toLogin() {
            this.features.navigator.navigateTo({
                url: '/login'
            }, undefined, true)
        }
    },
    lifetimes: {
        async ready() {
			// 这里检查用户是否已经登录因为作为oauth服务时必须是通过一个已经登录的用户进行授权
            this.setState({
                userId: this.features.token.getUserId(true) || '',
                dispose: this.features.token.subscribe(() => {
                    this.setState({
                        userId: this.features.token.getUserId(true) || '',
                    });
                })
            })
        },
        async detached() {
            this.state.dispose && this.state.dispose();
        },
    }
});

  • styles.module.less
.root {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 100vh; /* 使页面高度占满视口 */
    background: #f5f7fa; /* 可根据主题调整 */
}

.title {
    margin: 12px 0 18px;
    font-size: 20px;
    font-weight: 600;
    text-align: center;
    color: #222;
}

.container {
    width: 100%;
    max-width: 480px;
    min-height: 220px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background: #ffffff;
    border-radius: 8px;
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06);
}

  • web.pc.tsx
import React from 'react';
import { EntityDict } from '@project/oak-app-domain';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import Auth from "oak-general-business/es/components/login/oauth/authorize"
import Login from '@project/components/login';

const Authorize = (
    props: WebComponentProps<
        EntityDict,
        keyof EntityDict,
        false,
        {
            // virtual
            userId: string;
        },
        {
            toLogin: () => Promise<void>;
        }
    >
) => {
    const { userId } = props.data;
    const { toLogin, t } = props.methods;

    if (!userId) { // 如果未登录则显示登录页通过监听token在登录之后会显示认证页
        return (
            <div className={Styles.root}>
                <div className={Styles.title}>
                    {t('oauth.authorize.loginRequired')}
                </div>
                <div className={Styles.container}>
                    <Login // 这里可以自定义登录页的样式或者直接使用下面公共登录页
                        oakPath={`#login-oauth-authorize`}
                    />
                </div>
            </div>
        )
    }

    return <Auth
        oakPath={`#Authorize`}
        onUnLogin={toLogin}
    />;
};

export default Authorize;

oauth/callback:

  • locales/zh_CN.json
{
    
}
  • index.ts
export default OakComponent({
    // Virtual Component
    isList: false,
    filters: [],
    properties: {},
    methods: {
        // 认证失败时,用户点击重试,会调用该回调
        async handleRetry() {
            this.features.navigator.redirectTo({
                url: '/login'
            }, undefined, true)
        },
        // 认证成功后,用户点击完成按钮,会调用该回调
        async handleSuccess() {
            this.features.navigator.redirectTo({
                url: '/'
            }, undefined, true)
        }
    }
});

  • styles.module.less
.id {
    font-size: 18px;
}

.item {
    font-size: 18px;
}
  • web.pc.tsx
import React from 'react';
import { EntityDict } from '@project/oak-app-domain';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import OAuth from "oak-general-business/es/components/oauth"

const Oauth = (
    props: WebComponentProps<
        EntityDict,
        keyof EntityDict,
        false,
        {
            // virtual
        },
        {
            handleRetry: () => void;
            handleSuccess: () => void;
        }
    >
) => {
    const { oakFullpath } = props.data;
    const { handleRetry, handleSuccess } = props.methods;

    return <OAuth
        oakPath={`#OAuth`}
        onRetry={ handleRetry }
        onSuccess={ handleSuccess }
    ></OAuth>
};

export default Oauth;

配置OAuth

首先在作为认证服务的供应商创建一个oauth客户端以oak为例

  • 打开systemPanel系统设置页面
    • 左侧边栏选择OAuth管理
    • tab页选择OAuth应用
      • 点击创建
        • 名称描述随意
        • logo随意
        • 重定向URL列表也就是客户端的callback页面的位置例如: http://localhost:3000/oauth/callback
        • 权限范围: 可为空,现未做判断
        • 客户端ID 需要记下
        • 机密客户端(启用)
        • 强制PKCE不启用
      • 创建完成
      • 点击更新,以查看客户端密钥,并记录

然后配置需要使用第三方登录的应用的配置以oak为例