mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
core: support oauth bind/unbind account (#971)
Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
1 parent
1a8232899c
commit
536c697f4b
16 files changed
+393
-141
No files matched your search
@@ -8,7 +8,7 @@ import yaml from 'js-yaml';
|
||||
import { Context } from '../context';
|
||||
import { Logger } from '../logger';
|
||||
import { PRIV } from '../model/builtin';
|
||||
import { unwrapExports } from '../utils';
|
||||
import { isClass, unwrapExports } from '../utils';
|
||||
|
||||
const logger = new Logger('common');
|
||||
|
||||
@@ -52,7 +52,8 @@ export async function builtinModel(ctx: Context) {
|
||||
const models = await fs.readdir(modelDir);
|
||||
for (const t of models.filter((i) => i.endsWith('.ts'))) {
|
||||
const q = path.resolve(modelDir, t);
|
||||
if ('apply' in require(q)) ctx.loader.reloadPlugin(q, '');
|
||||
const module = require(q);
|
||||
if ('apply' in module || isClass(unwrapExports(module))) ctx.loader.reloadPlugin(q, '');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ class HomeSecurityHandler extends Handler {
|
||||
this.translate('geoip_locale'),
|
||||
);
|
||||
}
|
||||
const relations = await this.ctx.oauth.list(this.user._id);
|
||||
this.response.template = 'home_security.html';
|
||||
this.response.body = {
|
||||
sudoUid: this.session.sudoUid || null,
|
||||
@@ -202,6 +203,7 @@ class HomeSecurityHandler extends Handler {
|
||||
'authenticatorAttachment', 'regat', 'fmt',
|
||||
])),
|
||||
geoipProvider: this.ctx.geoip?.provider,
|
||||
relations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,6 +252,13 @@ class HomeSecurityHandler extends Handler {
|
||||
this.response.template = 'user_changemail_mail_sent.html';
|
||||
}
|
||||
|
||||
@param('platform', Types.String)
|
||||
async postLinkAccount({ }, platform: string) {
|
||||
if (!global.Hydro.module.oauth[platform]) throw new ValidationError('platform');
|
||||
this.session.oauthBind = platform;
|
||||
await global.Hydro.module.oauth[platform].get.call(this);
|
||||
}
|
||||
|
||||
@param('tokenDigest', Types.String)
|
||||
async postDeleteToken({ }, tokenDigest: string) {
|
||||
const sessions = await token.getSessionListByUid(this.user._id);
|
||||
@@ -609,7 +618,7 @@ class HomeMessagesConnectionHandler extends ConnectionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
export const inject = { geoip: { required: false } };
|
||||
export const inject = { geoip: { required: false }, oauth: {} };
|
||||
export function apply(ctx: Context) {
|
||||
ctx.Route('homepage', '/', HomeHandler);
|
||||
ctx.Route('home_security', '/home/security', HomeSecurityHandler, PRIV.PRIV_USER_PROFILE);
|
||||
|
||||
@@ -4,7 +4,7 @@ import moment from 'moment-timezone';
|
||||
import type { Binary } from 'mongodb';
|
||||
import type { Context } from '../context';
|
||||
import {
|
||||
AuthOperationError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError,
|
||||
AuthOperationError, BadRequestError, BlacklistedError, BuiltinLoginError, ForbiddenError, InvalidTokenError,
|
||||
SystemError, UserAlreadyExistError, UserFacingError,
|
||||
UserNotFoundError, ValidationError, VerifyPasswordError,
|
||||
} from '../error';
|
||||
@@ -16,7 +16,6 @@ import BlackListModel from '../model/blacklist';
|
||||
import { PERM, PRIV, STATUS } from '../model/builtin';
|
||||
import * as ContestModel from '../model/contest';
|
||||
import domain from '../model/domain';
|
||||
import oauth from '../model/oauth';
|
||||
import * as oplog from '../model/oplog';
|
||||
import problem, { ProblemDoc } from '../model/problem';
|
||||
import ScheduleModel from '../model/schedule';
|
||||
@@ -28,6 +27,18 @@ import {
|
||||
Handler, param, post, Types,
|
||||
} from '../service/server';
|
||||
|
||||
async function successfulAuth(this: Handler, udoc: User) {
|
||||
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
|
||||
this.context.HydroContext.user = udoc;
|
||||
this.session.viewLang = '';
|
||||
this.session.uid = udoc._id;
|
||||
this.session.sudo = null;
|
||||
this.session.sudoUid = null;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
this.session.oauthBind = null;
|
||||
this.session.recreate = true;
|
||||
}
|
||||
|
||||
class UserLoginHandler extends Handler {
|
||||
noCheckPermView = true;
|
||||
async prepare() {
|
||||
@@ -74,15 +85,9 @@ class UserLoginHandler extends Handler {
|
||||
} else throw new ValidationError('2FA', 'Authn');
|
||||
}
|
||||
await udoc.checkPassword(password);
|
||||
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
|
||||
if (!udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) throw new BlacklistedError(uname, udoc.banReason);
|
||||
this.context.HydroContext.user = udoc;
|
||||
this.session.viewLang = '';
|
||||
this.session.uid = udoc._id;
|
||||
this.session.sudo = null;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
await successfulAuth.call(this, udoc);
|
||||
this.session.save = rememberme;
|
||||
this.session.recreate = true;
|
||||
this.response.redirect = redirect || ((this.request.referer || '/login').endsWith('/login')
|
||||
? this.url('homepage') : this.request.referer);
|
||||
}
|
||||
@@ -193,15 +198,12 @@ class UserLogoutHandler extends Handler {
|
||||
}
|
||||
|
||||
async post({ domainId }) {
|
||||
this.context.HydroContext.user = await user.getById(domainId, 0);
|
||||
this.session.uid = 0;
|
||||
this.session.sudo = null;
|
||||
this.session.sudoUid = null;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
await successfulAuth.call(this, await user.getById(domainId, 0));
|
||||
this.response.redirect = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// rename to RegisterSendMailHandler
|
||||
export class UserRegisterHandler extends Handler {
|
||||
noCheckPermView = true;
|
||||
async prepare() {
|
||||
@@ -225,7 +227,15 @@ export class UserRegisterHandler extends Handler {
|
||||
const t = await token.add(
|
||||
token.TYPE_REGISTRATION,
|
||||
system.get('session.unsaved_expire_seconds'),
|
||||
{ mail, redirect: this.domain.registerRedirect },
|
||||
{
|
||||
mail,
|
||||
redirect: this.domain.registerRedirect,
|
||||
identity: {
|
||||
provider: 'mail',
|
||||
platform: 'mail',
|
||||
id: mail,
|
||||
},
|
||||
},
|
||||
);
|
||||
const prefix = this.domain.host
|
||||
? `${this.domain.host instanceof Array ? this.domain.host[0] : this.domain.host}`
|
||||
@@ -249,7 +259,7 @@ class UserRegisterWithCodeHandler extends Handler {
|
||||
@param('code', Types.String)
|
||||
async prepare({ }, code: string) {
|
||||
this.tdoc = await token.get(code, token.TYPE_REGISTRATION);
|
||||
if (!this.tdoc || (!this.tdoc.mail && !this.tdoc.phone)) {
|
||||
if (!this.tdoc?.identity) {
|
||||
// prevent brute forcing tokens
|
||||
await this.limitRate('user_register_with_code', 60, 5);
|
||||
throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_REGISTRATION], code);
|
||||
@@ -269,28 +279,22 @@ class UserRegisterWithCodeHandler extends Handler {
|
||||
domainId: string, password: string, verify: string,
|
||||
uname = '', code: string,
|
||||
) {
|
||||
if (this.tdoc.oauth?.[0] && global.Hydro.module.oauth[this.tdoc.oauth[0]].lockUsername) {
|
||||
uname = this.tdoc.username;
|
||||
if (global.Hydro.module.oauth[this.tdoc.identity.provider].lockUsername) {
|
||||
uname = this.tdoc.identity.username;
|
||||
}
|
||||
if (!Types.Username[1](uname)) throw new ValidationError('uname');
|
||||
if (password !== verify) throw new VerifyPasswordError();
|
||||
if (this.tdoc.phone) this.tdoc.mail = `${String.random(12)}@hydro.local`;
|
||||
const uid = await user.create(this.tdoc.mail, uname, password, undefined, this.request.ip);
|
||||
const randomEmail = `${String.random(12)}@invalid.local`; // some random email to remove in the future
|
||||
const uid = await user.create(this.tdoc.mail || randomEmail, uname, password, undefined, this.request.ip);
|
||||
await token.del(code, token.TYPE_REGISTRATION);
|
||||
const [id, mailDomain] = this.tdoc.mail.split('@');
|
||||
const $set: any = this.tdoc.set || {};
|
||||
if (this.tdoc.phone) $set.phone = this.tdoc.phone;
|
||||
if (mailDomain === 'qq.com' && !Number.isNaN(+id)) $set.avatar = `qq:${id}`;
|
||||
if (this.session.viewLang) $set.viewLang = this.session.viewLang;
|
||||
if (Object.keys($set).length) await user.setById(uid, $set);
|
||||
if (Object.keys(this.tdoc.setInDomain || {}).length) await domain.setUserInDomain(domainId, uid, this.tdoc.setInDomain);
|
||||
if (this.tdoc.oauth) await oauth.set(this.tdoc.oauth[1], uid);
|
||||
this.context.HydroContext.user = await user.getById(domainId, uid);
|
||||
this.session.viewLang = '';
|
||||
this.session.uid = uid;
|
||||
this.session.sudoUid = null;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
this.session.recreate = true;
|
||||
await this.ctx.oauth.set(this.tdoc.identity.platform, this.tdoc.identity.id, uid);
|
||||
await successfulAuth.call(this, await user.getById(domainId, uid));
|
||||
this.response.redirect = this.tdoc.redirect || this.url('home_settings', { category: 'preference' });
|
||||
}
|
||||
}
|
||||
@@ -434,56 +438,57 @@ class OauthCallbackHandler extends Handler {
|
||||
async get(args: any) {
|
||||
if (!global.Hydro.module.oauth[args.type]) throw new UserFacingError('Oauth type');
|
||||
const r = await global.Hydro.module.oauth[args.type].callback.call(this, args);
|
||||
const uid = await oauth.get(r._id);
|
||||
if (uid) {
|
||||
await user.setById(uid, { loginat: new Date(), loginip: this.request.ip });
|
||||
this.session.uid = uid;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
this.response.redirect = '/';
|
||||
} else {
|
||||
if (r.email) {
|
||||
const udoc = await user.getByEmail('system', r.email);
|
||||
if (udoc) {
|
||||
await oauth.set(r._id, udoc._id);
|
||||
await user.setById(udoc._id, { loginat: new Date(), loginip: this.request.ip });
|
||||
this.session.uid = udoc._id;
|
||||
this.session.scope = PERM.PERM_ALL.toString();
|
||||
this.session.recreate = true;
|
||||
this.response.redirect = '/';
|
||||
return;
|
||||
}
|
||||
if (this.session.oauthBind === args.type) {
|
||||
delete this.session.oauthBind;
|
||||
const existing = await this.ctx.oauth.get(args.type, r._id);
|
||||
if (existing && existing !== this.user._id) {
|
||||
throw new BadRequestError('Already binded to another account');
|
||||
}
|
||||
this.checkPriv(PRIV.PRIV_REGISTER_USER);
|
||||
let username = '';
|
||||
r.uname ||= [];
|
||||
const mailDomain = r.email.split('@')[1];
|
||||
if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain);
|
||||
for (const uname of r.uname) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const nudoc = await user.getByUname('system', uname);
|
||||
if (!nudoc) {
|
||||
username = uname;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const set: Partial<Udoc> = { ...r.set, oauth: args.type };
|
||||
if (r.bio) set.bio = r.bio;
|
||||
if (r.viewLang) set.viewLang = r.viewLang;
|
||||
if (r.avatar) set.avatar = r.avatar;
|
||||
const [t] = await token.add(
|
||||
token.TYPE_REGISTRATION,
|
||||
system.get('session.unsaved_expire_seconds'),
|
||||
{
|
||||
mail: r.email,
|
||||
username,
|
||||
redirect: this.domain.registerRedirect,
|
||||
set,
|
||||
setInDomain: r.setInDomain,
|
||||
oauth: [args.type, r._id],
|
||||
},
|
||||
);
|
||||
this.response.redirect = this.url('user_register_with_code', { code: t });
|
||||
this.response.redirect = '/home/security';
|
||||
if (existing !== this.user._id) await this.ctx.oauth.set(args.type, r._id, this.user._id);
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = await this.ctx.oauth.get(args.type, r._id) || await this.ctx.oauth.get('mail', r.email);
|
||||
if (uid) {
|
||||
await successfulAuth.call(this, await user.getById('system', uid));
|
||||
this.response.redirect = '/';
|
||||
return;
|
||||
}
|
||||
this.checkPriv(PRIV.PRIV_REGISTER_USER);
|
||||
let username = '';
|
||||
r.uname ||= [];
|
||||
const mailDomain = r.email.split('@')[1];
|
||||
if (await BlackListModel.get(`mail::${mailDomain}`)) throw new BlacklistedError(mailDomain);
|
||||
for (const uname of r.uname) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const nudoc = await user.getByUname('system', uname);
|
||||
if (!nudoc) {
|
||||
username = uname;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const set: Partial<Udoc> = { ...r.set };
|
||||
if (r.bio) set.bio = r.bio;
|
||||
if (r.viewLang) set.viewLang = r.viewLang;
|
||||
if (r.avatar) set.avatar = r.avatar;
|
||||
const [t] = await token.add(
|
||||
token.TYPE_REGISTRATION,
|
||||
system.get('session.unsaved_expire_seconds'),
|
||||
{
|
||||
mail: r.email,
|
||||
username,
|
||||
redirect: this.domain.registerRedirect,
|
||||
set,
|
||||
setInDomain: r.setInDomain,
|
||||
identity: {
|
||||
provider: args.type,
|
||||
platform: args.type,
|
||||
id: r._id,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.response.redirect = this.url('user_register_with_code', { code: t });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +510,8 @@ class ContestModeHandler extends Handler {
|
||||
}
|
||||
}
|
||||
|
||||
export const inject = ['oauth'];
|
||||
|
||||
export async function apply(ctx: Context) {
|
||||
ctx.Route('user_login', '/login', UserLoginHandler);
|
||||
ctx.Route('user_oauth', '/oauth/:type', OauthHandler);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@hydrooj/common/types';
|
||||
import type { Context } from './context';
|
||||
import type { DocStatusType } from './model/document';
|
||||
import type { OauthMap } from './model/oauth';
|
||||
import type { ProblemDoc } from './model/problem';
|
||||
import type { Handler } from './service/server';
|
||||
|
||||
@@ -505,12 +506,7 @@ export interface OpCountDoc {
|
||||
opcount: number;
|
||||
}
|
||||
|
||||
export interface OauthMap {
|
||||
/** source openId */
|
||||
_id: string;
|
||||
/** target uid */
|
||||
uid: number;
|
||||
}
|
||||
export type { OauthMap } from './model/oauth';
|
||||
|
||||
export interface DiscussionHistoryDoc {
|
||||
title?: string;
|
||||
@@ -628,6 +624,7 @@ export interface UI {
|
||||
export interface ModuleInterfaces {
|
||||
oauth: {
|
||||
text: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
get: (this: Handler) => Promise<void>;
|
||||
callback: (this: Handler, args: Record<string, any>) => Promise<OAuthUserResponse>;
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import db from '../service/db';
|
||||
import { Collection } from 'mongodb';
|
||||
import { Context, Service } from '../context';
|
||||
|
||||
const coll = db.collection('oauth');
|
||||
export interface OauthMap {
|
||||
platform: string;
|
||||
/** source openId */
|
||||
id: string;
|
||||
/** target uid */
|
||||
uid: number;
|
||||
}
|
||||
|
||||
class OauthModel {
|
||||
static async get(_id: string) {
|
||||
const doc = await coll.findOne({ _id });
|
||||
declare module '../context' {
|
||||
interface Context {
|
||||
oauth: OauthModel;
|
||||
}
|
||||
}
|
||||
|
||||
export default class OauthModel extends Service {
|
||||
static inject = ['db'];
|
||||
coll: Collection<OauthMap>;
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'oauth');
|
||||
this.coll = ctx.db.collection('oauth');
|
||||
}
|
||||
|
||||
async [Context.init]() {
|
||||
await this.ctx.db.ensureIndexes(this.coll,
|
||||
// { key: { platform: 1, id: 1 }, name: 'platform_id', unique: true },
|
||||
{ key: { uid: 1, platform: 1 }, name: 'uid_platform' },
|
||||
);
|
||||
console.log('oauth init');
|
||||
}
|
||||
|
||||
async get(platform: string, id: string) {
|
||||
const doc = await this.coll.findOne({ platform, id });
|
||||
if (doc) return doc.uid;
|
||||
return null;
|
||||
}
|
||||
|
||||
static async set(_id: string, uid: number) {
|
||||
const res = await coll.findOneAndUpdate(
|
||||
{ _id },
|
||||
async set(platform: string, id: string, uid: number) {
|
||||
const res = await this.coll.findOneAndUpdate(
|
||||
{ platform, id },
|
||||
{ $set: { uid } },
|
||||
{ upsert: true, returnDocument: 'after' },
|
||||
);
|
||||
return res?.uid;
|
||||
}
|
||||
}
|
||||
|
||||
export default OauthModel;
|
||||
global.Hydro.model.oauth = OauthModel;
|
||||
async unbind(platform: string, id: string, uid: number) {
|
||||
await this.coll.deleteOne({ platform, id, uid });
|
||||
}
|
||||
|
||||
async list(uid: number) {
|
||||
return this.coll.find({ uid }).toArray();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Logger } from '@hydrooj/utils';
|
||||
import { Context, Service } from '../context';
|
||||
import * as system from '../model/system';
|
||||
|
||||
export type MigrationScript = null | (() => Promise<boolean | void>);
|
||||
export type MigrationScript = null | ((ctx: Context) => Promise<boolean | void>);
|
||||
const logger = new Logger('migration');
|
||||
|
||||
declare module '../context' {
|
||||
@@ -39,7 +39,7 @@ export default class MigrationService extends Service {
|
||||
const isFresh = !dbVer;
|
||||
const expected = scripts.length;
|
||||
if (isFresh) {
|
||||
await scripts[0]?.();
|
||||
await scripts[0]?.(this.ctx);
|
||||
await system.set(name, expected);
|
||||
continue;
|
||||
}
|
||||
@@ -53,11 +53,11 @@ export default class MigrationService extends Service {
|
||||
if ('dontWait' in func) {
|
||||
logger.info('[Background Task]');
|
||||
// For those scripts we don't really care if they fail
|
||||
func().then(() => {
|
||||
func(this.ctx).then(() => {
|
||||
logger.info('Background Task Completed [%s]: from %d to %d', channel, func.dontWait, expected);
|
||||
}).catch(logger.error);
|
||||
} else {
|
||||
const result = await func();
|
||||
const result = await func(this.ctx);
|
||||
if (!result) break;
|
||||
}
|
||||
dbVer++;
|
||||
|
||||
@@ -264,6 +264,7 @@ export async function apply(ctx: Context) {
|
||||
id: key,
|
||||
icon: global.Hydro.module.oauth[key].icon,
|
||||
text: global.Hydro.module.oauth[key].text,
|
||||
name: global.Hydro.module.oauth[key].name,
|
||||
}));
|
||||
if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW);
|
||||
if (h.context.pendingError) throw h.context.pendingError;
|
||||
|
||||
@@ -587,4 +587,17 @@ export const coreScripts: MigrationScript[] = [
|
||||
);
|
||||
return true;
|
||||
},
|
||||
async function _92_93(ctx) {
|
||||
await ctx.inject(['oauth'], async (c) => {
|
||||
await iterateAllUser(async (udoc) => {
|
||||
if (udoc.mailLower.endsWith('.local')) return;
|
||||
await c.oauth.set('mail', udoc.mailLower, udoc._id);
|
||||
});
|
||||
const old = await c.oauth.coll.find({ _id: { $type: 'string' } }).toArray();
|
||||
for (const r of old) {
|
||||
if ((r._id as any).endsWith('@github.local')) await c.oauth.set('github', (r._id as any).split('@')[0], r.uid);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user