core: support oauth bind/unbind account (#971)

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and GitHub committed 2025-04-01 10:55:21 +01:00
1 parent 1a8232899c
commit 536c697f4b
16 files changed
+393 -141

No files matched your search

+10 -1
View File
@@ -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);
+83 -76
View File
@@ -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);