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

@@ -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;