feat: add Oauth2 Login (#420)

This commit is contained in:
Dream Hunter
2024-08-18 14:39:50 +08:00
committed by GitHub
parent 6d4783e1cd
commit b5b59acdb3
27 changed files with 614 additions and 17 deletions

View File

@@ -6,6 +6,7 @@
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
- fix: UI 修复登录页面 tab 激活图标错位
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
## v0.7.2

View File

@@ -0,0 +1,8 @@
const COMMOM_MAIL = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
export default {
COMMOM_MAIL
}

View File

@@ -0,0 +1,15 @@
export type UserOauth2Settings = {
name: string;
clientID: string;
clientSecret: string;
authorizationURL: string;
accessTokenURL: string;
accessTokenFormat?: string;
userInfoURL: string;
redirectURL: string;
logoutURL?: string;
userEmailKey: string;
scope: string;
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.vue'
import { useGlobalState } from '../store'
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
const router = createRouter({
history: createWebHistory(),
@@ -16,6 +16,11 @@ const router = createRouter({
alias: "/:lang/user",
component: User
},
{
path: '/user/oauth2/callback',
alias: "/:lang/user/oauth2/callback",
component: UserOauth2Callback
},
{
path: '/admin',
alias: "/:lang/admin",

View File

@@ -1,5 +1,8 @@
import { computed, ref } from "vue";
import { createGlobalState, useStorage, useDark, useToggle, useLocalStorage } from '@vueuse/core'
import {
createGlobalState, useStorage, useDark, useToggle,
useLocalStorage, useSessionStorage
} from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
@@ -42,7 +45,7 @@ export const useGlobalState = createGlobalState(
name: '',
}
});
const sendMailModel = useStorage('sendMailModel', {
const sendMailModel = useSessionStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
@@ -56,21 +59,23 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const adminTab = ref("account");
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useStorage('userTab', 'user_settings');
const indexTab = useStorage('indexTab', 'mailbox');
const userTab = useSessionStorage('userTab', 'user_settings');
const indexTab = useSessionStorage('indexTab', 'mailbox');
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const userOpenSettings = ref({
fetched: false,
enable: false,
enableMailVerify: false,
/** @type {{ clientID: string, name: string }[]} */
oauth2ClientIDs: [],
});
const userSettings = ref({
/** @type {boolean} */
@@ -93,6 +98,8 @@ export const useGlobalState = createGlobalState(
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
return {
isDark,
toggleDark,
@@ -123,6 +130,8 @@ export const useGlobalState = createGlobalState(
telegramApp,
isTelegram,
showAdminPage,
userOauth2SessionState,
userOauth2SessionClientID,
}
},
)

View File

@@ -13,6 +13,7 @@ import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -49,6 +50,7 @@ const { t } = useI18n({
user: 'User',
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -71,6 +73,7 @@ const { t } = useI18n({
user: '用户',
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -135,6 +138,9 @@ onMounted(async () => {
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">

View File

@@ -0,0 +1,253 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
import constant from '../../constant'
import { UserOauth2Settings } from '../../models';
const { loading } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
save: 'Save',
successTip: 'Save Success',
enable: 'Enable',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
mailAllowList: 'Mail Address Allow List',
addOauth2: 'Add Oauth2',
name: 'Name',
oauth2Type: 'Oauth2 Type',
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
},
zh: {
save: '保存',
successTip: '保存成功',
enable: '启用',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
mailAllowList: '邮件地址白名单',
addOauth2: '添加 Oauth2',
name: '名称',
oauth2Type: 'Oauth2 类型',
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
}
}
});
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
return { label: item, value: item }
})
const userOauth2Settings = ref([] as UserOauth2Settings[])
const showAddOauth2 = ref(false)
const newOauth2Name = ref('')
const newOauth2Type = ref('custom')
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/user_oauth2_settings`)
Object.assign(userOauth2Settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const save = async () => {
try {
await api.fetch(`/admin/user_oauth2_settings`, {
method: 'POST',
body: JSON.stringify(userOauth2Settings.value)
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const addNewOauth2 = () => {
const authorizationURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/authorize'
case 'authentik':
return 'https://youdomain/application/o/authorize/'
default:
return ''
}
}
const accessTokenURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/access_token'
case 'authentik':
return 'https://youdomain/application/o/token/'
default:
return ''
}
}
const accessTokenFormat = () => {
switch (newOauth2Type.value) {
case 'github':
return 'json'
case 'authentik':
return 'urlencoded'
default:
return ''
}
}
const userInfoURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://api.github.com/user'
case 'authentik':
return 'https://youdomain/application/o/userinfo/'
default:
return ''
}
}
const userEmailKey = () => {
switch (newOauth2Type.value) {
case 'github':
return 'email'
case 'authentik':
return 'email'
default:
return ''
}
}
const scope = () => {
switch (newOauth2Type.value) {
case 'github':
return 'user:email'
case 'authentik':
return 'email openid'
default:
return ''
}
}
userOauth2Settings.value.push({
name: newOauth2Name.value,
clientID: '',
clientSecret: '',
authorizationURL: authorizationURL(),
accessTokenURL: accessTokenURL(),
accessTokenFormat: accessTokenFormat(),
userInfoURL: userInfoURL(),
userEmailKey: userEmailKey(),
redirectURL: `${window.location.origin}/user/oauth2/callback`,
logoutURL: '',
scope: scope(),
enableMailAllowList: false,
mailAllowList: constant.COMMOM_MAIL
} as UserOauth2Settings)
newOauth2Name.value = ''
showAddOauth2.value = false
}
const accessTokenFormatOptions = [
{ label: 'json', value: 'json' },
{ label: 'urlencoded', value: 'urlencoded' },
]
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-modal v-model:show="showAddOauth2" preset="dialog" :title="t('addOauth2')">
<n-form>
<n-form-item-row :label="t('name')" required>
<n-input v-model:value="newOauth2Name" />
</n-form-item-row>
<n-form-item-row :label="t('oauth2Type')" required>
<n-radio-group v-model:value="newOauth2Type">
<n-radio-button value="github" label="Github" />
<n-radio-button value="authentik" label="Authentik" />
<n-radio-button value="custom" label="Custom" />
</n-radio-group>
</n-form-item-row>
</n-form>
<template #action>
<n-button :loading="loading" @click="addNewOauth2" size="small" tertiary type="primary">
{{ t('addOauth2') }}
</n-button>
</template>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" closable style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-flex justify="end">
<n-button @click="showAddOauth2 = true" secondary :loading="loading">
{{ t('addOauth2') }}
</n-button>
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-collapse default-expanded-names="1" accordion>
<n-collapse-item v-for="(item, index) in userOauth2Settings" :key="index" :title="item.name">
<n-form :model="item">
<n-form-item-row :label="t('name')" required>
<n-input v-model:value="item.name" />
</n-form-item-row>
<n-form-item-row label="Client ID" required>
<n-input v-model:value="item.clientID" />
</n-form-item-row>
<n-form-item-row label="Client Secret" required>
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
</n-form-item-row>
<n-form-item-row label="Authorization URL" required>
<n-input v-model:value="item.authorizationURL" />
</n-form-item-row>
<n-form-item-row label="Access Token URL" required>
<n-input v-model:value="item.accessTokenURL" />
</n-form-item-row>
<n-form-item-row label="Access Token accessTokenFormat" required>
<n-select v-model:value="item.accessTokenFormat" :options="accessTokenFormatOptions" />
</n-form-item-row>
<n-form-item-row label="User Info URL" required>
<n-input v-model:value="item.userInfoURL" />
</n-form-item-row>
<n-form-item-row label="User Email Key" required>
<n-input v-model:value="item.userEmailKey" />
</n-form-item-row>
<n-form-item-row label="Redirect URL" required>
<n-input v-model:value="item.redirectURL" />
</n-form-item-row>
<n-form-item-row label="Scope" required>
<n-input v-model:value="item.scope" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailAllowList')">
<n-input-group>
<n-checkbox v-model:checked="item.enableMailAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="item.mailAllowList" v-if="item.enableMailAllowList" filterable
multiple tag style="width: 80%;" :options="mailAllowOptions"
:placeholder="t('mailAllowList')" />
</n-input-group>
</n-form-item-row>
</n-form>
</n-collapse-item>
</n-collapse>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -2,6 +2,7 @@
import { useMessage } from 'naive-ui'
import { onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n'
import { KeyFilled } from '@vicons/material'
import { api } from '../../api';
import { useGlobalState } from '../../store'
@@ -10,7 +11,10 @@ import { startAuthentication } from '@simplewebauthn/browser';
import Turnstile from '../../components/Turnstile.vue';
const { userJwt, userOpenSettings, openSettings } = useGlobalState()
const {
userJwt, userOpenSettings, openSettings,
userOauth2SessionState, userOauth2SessionClientID
} = useGlobalState()
const message = useMessage();
const { t } = useI18n({
@@ -33,6 +37,7 @@ const { t } = useI18n({
pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login',
loginWithPasskey: 'Login with Passkey',
loginWith: 'Login with {provider}',
},
zh: {
login: '登录',
@@ -52,6 +57,7 @@ const { t } = useI18n({
pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录',
loginWithPasskey: '使用 Passkey 登录',
loginWith: '使用 {provider} 登录',
}
}
});
@@ -184,6 +190,18 @@ const passkeyLogin = async () => {
}
};
const oauth2Login = async (clientID) => {
try {
userOauth2SessionClientID.value = clientID;
userOauth2SessionState.value = Math.random().toString(36).substring(2);
const res = await api.fetch(`/user_api/oauth2/login_url?clientID=${clientID}&state=${userOauth2SessionState.value}`);
// redirect to oauth2 login page
location.href = res.url;
} catch (error) {
message.error(error.message || "login failed");
}
};
onMounted(async () => {
});
@@ -208,8 +226,15 @@ onMounted(async () => {
</n-button>
<n-divider />
<n-button @click="passkeyLogin" type="primary" block secondary strong>
<template #icon>
<n-icon :component="KeyFilled" />
</template>
{{ t('loginWithPasskey') }}
</n-button>
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
:key="item.clientID" block secondary strong>
{{ t('loginWith', { provider: item.name }) }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
@@ -276,4 +301,8 @@ onMounted(async () => {
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router';
import { useGlobalState } from '../../store'
import { api } from '../../api';
const {
userJwt, userOauth2SessionState, userOauth2SessionClientID
} = useGlobalState()
const message = useMessage();
const route = useRoute()
const router = useRouter()
const errorInfo = ref('')
const { t } = useI18n({
messages: {
en: {
logging: 'Logging in...',
stateNotMatch: 'state not match',
},
zh: {
logging: '登录中...',
stateNotMatch: 'state 不匹配',
}
}
});
onMounted(async () => {
const state = route.query.state;
if (state != userOauth2SessionState.value) {
console.error('state not match');
message.error(t('stateNotMatch'));
return;
}
const code = route.query.code;
if (!code) {
console.error('code not found');
message.error('code not found');
return;
}
try {
const res = await api.fetch(`/user_api/oauth2/callback`, {
method: 'POST',
body: JSON.stringify({
code: code,
clientID: userOauth2SessionClientID.value
})
});
userJwt.value = res.jwt;
router.push('/user');
} catch (error) {
console.error(error);
message.error(error.message || 'error');
}
});
</script>
<template>
<n-card :bordered="false" embedded>
<n-result status="info" :title="t('logging')" :description="errorInfo">
</n-result>
</n-card>
</template>

View File

@@ -96,7 +96,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过命令行部署',
collapsed: true,
collapsed: false,
items: [
{ text: '命令行部署准备', link: 'cli/pre-requisite' },
{ text: 'D1 数据库', link: 'cli/d1' },
@@ -108,7 +108,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过用户界面部署',
collapsed: true,
collapsed: false,
items: [
{ text: 'D1 数据库', link: 'ui/d1' },
{ text: 'Cloudflare workers 后端', link: 'ui/worker' },
@@ -119,14 +119,14 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过 Github Actions 部署',
collapsed: true,
collapsed: false,
items: [
{ text: '通过 Github Actions 部署', link: 'github-action' },
]
},
{
text: '附加功能',
collapsed: true,
collapsed: false,
items: [
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
@@ -137,11 +137,12 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置 webhook', link: 'feature/webhook' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
]
},
{
text: '功能简介',
collapsed: true,
collapsed: false,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,26 @@
# OAuth2 第三方登录
> [!WARNING]
> 第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号)
>
> 此账号和注册的账号相同, 也可以通过忘记密码设置密码
## 在第三方平台注册 OAuth2
### GitHub
- 请先创建一个 OAuth App然后获取 `Client ID``Client Secret`
参考 [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
### Authentik
- [Authentik OAuth2 Provider](https://docs.goauthentik.io/docs/providers/oauth2/)
## Admin 后台配置 OAuth2
![oauth2](/feature/oauth2.png)
## 测试用户登录页面
![oauth2 login](/feature/oauth2-login.png)

View File

@@ -9,6 +9,7 @@ import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
export const api = new Hono<HonoCustomType>()
@@ -313,6 +314,10 @@ api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
// user oauth2 settings
api.get('/admin/user_oauth2_settings', oauth2_settings.getUserOauth2Settings)
api.post('/admin/user_oauth2_settings', oauth2_settings.saveUserOauth2Settings)
// webhook settings
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);

View File

@@ -0,0 +1,34 @@
import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { UserOauth2Settings } from "../models";
import { HonoCustomType } from '../types';
import { getJsonSetting, saveSetting } from '../utils';
async function getUserOauth2Settings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
return c.json(settings || []);
}
async function saveUserOauth2Settings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<UserOauth2Settings[]>();
for (const setting of settings) {
if (!setting.name || !setting.clientID || !setting.clientSecret
|| !setting.authorizationURL || !setting.accessTokenURL
|| !setting.accessTokenFormat
|| !setting.userInfoURL || !setting.redirectURL
|| !setting.userEmailKey || !setting.scope) {
return c.text(`${setting.name} is missing required fields`, 400);
}
if (setting.enableMailAllowList && (setting.mailAllowList?.length || 0) < 1) {
return c.text(`${setting.name} is missing mail allow list`, 400);
}
}
await saveSetting(c, CONSTANTS.OAUTH2_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
}
export default {
getUserOauth2Settings,
saveUserOauth2Settings,
}

View File

@@ -6,6 +6,7 @@ export const CONSTANTS = {
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
// KV

View File

@@ -136,3 +136,19 @@ export class WebhookSettings {
"parsedHtml": "${parsedHtml}",
}, null, 2)
}
export type UserOauth2Settings = {
name: string;
clientID: string;
clientSecret: string;
authorizationURL: string;
accessTokenURL: string;
accessTokenFormat: string;
userInfoURL: string;
redirectURL: string;
logoutURL?: string;
userEmailKey: string;
scope: string;
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}

View File

@@ -5,6 +5,7 @@ import settings from './settings';
import user from './user';
import bind_address from './bind_address';
import passkey from './passkey';
import oauth2 from './oauth2';
export const api = new Hono<HonoCustomType>();
@@ -17,6 +18,10 @@ api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register);
// oauth2 api
api.get('/user_api/oauth2/login_url', oauth2.getOauth2LoginUrl);
api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
// bind address api
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind);

View File

@@ -0,0 +1,105 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from '../types';
import { getJsonSetting } from '../utils';
import { UserOauth2Settings } from '../models';
import { CONSTANTS } from '../constants';
export default {
getOauth2LoginUrl: async (c: Context<HonoCustomType>) => {
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
const { clientID, state } = c.req.query();
const setting = settings?.find(s => s.clientID === clientID);
if (!setting) {
return c.text("Client not found", 400);
}
const url = `${setting.authorizationURL}?client_id=${setting.clientID}&response_type=code&redirect_uri=${setting.redirectURL}&scope=${setting.scope}&state=${state}`
return c.json({ url });
},
oauth2Login: async (c: Context<HonoCustomType>) => {
const { clientID, code } = await c.req.json<{ clientID?: string, code?: string }>();
if (!clientID || !code) {
return c.text("clientID or code is missing", 400);
}
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
const setting = settings?.find(s => s.clientID === clientID);
if (!setting) {
return c.text("Client not found", 400);
}
const params = {
code,
client_id: setting.clientID,
client_secret: setting.clientSecret,
grant_type: 'authorization_code',
}
const res = await fetch(setting.accessTokenURL, {
method: 'POST',
body: setting.accessTokenFormat === 'json'
? JSON.stringify(params) :
new URLSearchParams(params).toString(),
headers: {
'Content-Type': setting.accessTokenFormat === 'json'
? 'application/json'
: 'application/x-www-form-urlencoded',
"Accept": "application/json"
}
})
if (!res.ok) {
console.error(`Failed to get access token: ${res.status} ${res.statusText} ${await res.text()}`)
return c.text("Failed to get access token", 400);
}
const resJson = await res.json();
const { access_token, token_type } = resJson as { access_token: string, token_type?: string };
const user = await fetch(setting.userInfoURL, {
headers: {
"Authorization": `${token_type || 'Bearer'} ${access_token}`,
"Accept": "application/json",
"User-Agent": "Cloudflare Workers"
}
})
if (!user.ok) {
console.error(`Failed to get user info: ${res.status} ${res.statusText} ${await res.text()}`)
return c.text("Failed to get user info", 400);
}
const userInfo = await user.json()
const { [setting.userEmailKey]: email } = userInfo as { [key: string]: string };
if (!email) {
return c.text("Failed to get user email", 400);
}
// check email in mail allow list
const mailDomain = email.split("@")[1];
if (setting.enableMailAllowList && !setting.mailAllowList?.includes(mailDomain)) {
return c.text(`Mail domain must in ${JSON.stringify(setting.mailAllowList, null, 2)}`, 400)
}
// insert or update user
const { success } = await c.env.DB.prepare(
`INSERT INTO users (user_email, password, user_info)`
+ ` VALUES (?, '', ?)`
+ ` ON CONFLICT(user_email) DO UPDATE SET updated_at = datetime('now')`
).bind(
email, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
}
const { id: user_id } = await c.env.DB.prepare(
`SELECT id FROM users where user_email = ?`
).bind(email).first() || {};
if (!user_id) {
return c.text("User not found", 400)
}
// create jwt
const jwt = await Jwt.sign({
user_email: email,
user_id: user_id,
// 30 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
}
}

View File

@@ -1,7 +1,7 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { UserSettings } from "../models";
import { UserOauth2Settings, UserSettings } from "../models";
import { getJsonSetting, getUserRoles } from "../utils"
import { CONSTANTS } from "../constants";
import { commonGetUserRole } from "../common";
@@ -11,9 +11,22 @@ export default {
openSettings: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
const oauth2ClientIDs = [] as { clientID: string, name: string }[];
try {
const oauth2Settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
oauth2ClientIDs.push(
...oauth2Settings?.map(s => ({
clientID: s.clientID,
name: s.name
})) || []
);
} catch (e) {
console.error("Failed to get oauth2 settings", e);
}
return c.json({
enable: settings.enable,
enableMailVerify: settings.enableMailVerify,
oauth2ClientIDs: oauth2ClientIDs,
})
},
settings: async (c: Context<HonoCustomType>) => {

View File

@@ -1,17 +1,16 @@
import { Context } from "hono";
import { createMimeMessage } from "mimetext";
import { HonoCustomType, UserRole } from "./types";
import { User } from "telegraf/types";
export const getJsonSetting = async (
export const getJsonSetting = async <T = any>(
c: Context<HonoCustomType>, key: string
): Promise<any> => {
): Promise<T | null> => {
const value = await getSetting(c, key);
if (!value) {
return null;
}
try {
return JSON.parse(value);
return JSON.parse(value) as T;
} catch (e) {
console.error(`GetJsonSetting: Failed to parse ${key}`, e);
}

View File

@@ -127,6 +127,7 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code")
|| c.req.path.startsWith("/user_api/passkey/authenticate_")
|| c.req.path.startsWith("/user_api/oauth2")
) {
await next();
return;