mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +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;
|
||||
},
|
||||
];
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
TokenModel, UserFacingError, ValidationError,
|
||||
} from 'hydrooj';
|
||||
|
||||
// from https://github.com/logos
|
||||
// eslint-disable-next-line max-len
|
||||
const icon = '<svg viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>';
|
||||
|
||||
export default class LoginWithGithubService extends Service {
|
||||
static Config = Schema.object({
|
||||
id: Schema.string().description('Github OAuth AppID').required(),
|
||||
@@ -14,6 +18,8 @@ export default class LoginWithGithubService extends Service {
|
||||
super(ctx, 'oauth.github');
|
||||
ctx.provideModule('oauth', 'github', {
|
||||
text: 'Login with Github',
|
||||
name: 'Github',
|
||||
icon,
|
||||
callback: async function callback({ state, code }) {
|
||||
const s = await TokenModel.get(state, TokenModel.TYPE_OAUTH);
|
||||
if (!s) throw new ValidationError('token');
|
||||
@@ -38,7 +44,7 @@ export default class LoginWithGithubService extends Service {
|
||||
.set('Accept', 'application/vnd.github.v3+json')
|
||||
.set('Authorization', `token ${t}`);
|
||||
const ret = {
|
||||
_id: `${userInfo.body.id}@github.local`,
|
||||
_id: userInfo.body.id,
|
||||
email: userInfo.body.email,
|
||||
bio: userInfo.body.bio,
|
||||
uname: [userInfo.body.name, userInfo.body.login].filter((i) => i),
|
||||
|
||||
@@ -36,6 +36,7 @@ export default class GoogleOAuthService extends Service {
|
||||
super(ctx, 'oauth.google');
|
||||
ctx.provideModule('oauth', 'google', {
|
||||
text: 'Login with Google',
|
||||
name: 'Google',
|
||||
callback: async function callback(this: Handler, {
|
||||
state, code, error,
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import {
|
||||
Context, ForbiddenError, Handler, Schema, Service,
|
||||
} from 'hydrooj';
|
||||
|
||||
// from https://www.svgrepo.com/svg/333610/telegram
|
||||
// eslint-disable-next-line max-len
|
||||
const icon = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m20.665 3.717-17.73 6.837c-1.21.486-1.203 1.161-.222 1.462l4.552 1.42 10.532-6.645c.498-.303.953-.14.579.192l-8.533 7.701h-.002l.002.001-.314 4.692c.46 0 .663-.211.921-.46l2.211-2.15 4.599 3.397c.848.467 1.457.227 1.668-.785l3.019-14.228c.309-1.239-.473-1.8-1.282-1.434z"/></svg>';
|
||||
|
||||
export default class TelegramService extends Service {
|
||||
static Config = Schema.object({
|
||||
token: Schema.string().role('secret').required(),
|
||||
botLogin: Schema.string().required(),
|
||||
endpoint: Schema.string().default('https://api.telegram.org/bot'),
|
||||
});
|
||||
|
||||
constructor(ctx: Context, config: ReturnType<typeof TelegramService.Config>) {
|
||||
super(ctx, 'oauth.telegram');
|
||||
ctx.provideModule('oauth', 'telegram', {
|
||||
text: 'Login with Telegram',
|
||||
name: 'Telegram',
|
||||
icon,
|
||||
callback: async function callback({ payload }) {
|
||||
const parsed = JSON.parse(payload);
|
||||
const hash = parsed.hash;
|
||||
delete parsed.hash;
|
||||
const dataCheckString = Object.keys(parsed).sort().map((key) => (`${key}=${parsed[key]}`)).join('\n');
|
||||
const secretKey = createHash('sha256').update(config.token).digest();
|
||||
const dataCheckSum = createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
|
||||
if (hash !== dataCheckSum) throw new ForbiddenError('Invalid hash');
|
||||
const {
|
||||
id, first_name, last_name, username, photo_url,
|
||||
} = parsed;
|
||||
const ret = {
|
||||
_id: id,
|
||||
email: `${id}@telegram.local`,
|
||||
bio: '',
|
||||
uname: [`${first_name} ${last_name}`, username, `telegram${id.toString()}`].filter((i) => i),
|
||||
avatar: `url:${photo_url}`,
|
||||
};
|
||||
if (!ret.email) throw new ForbiddenError("You don't have a verified email.");
|
||||
return ret;
|
||||
},
|
||||
async get(this: Handler) {
|
||||
this.response.body = { botLogin: config.botLogin };
|
||||
this.response.template = 'telegram_login.html';
|
||||
},
|
||||
});
|
||||
ctx.i18n.load('zh', {
|
||||
'Login With Telegram': '使用 Telegram 登录',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@hydrooj/telegram",
|
||||
"version": "0.0.1"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "layout/immersive.html" %}
|
||||
{% block content %}
|
||||
<div class="row"><div class="columns">
|
||||
<div class="immersive--content immersive--center">
|
||||
<div class="row"><div class="columns">
|
||||
<h1>{{ _('Login with Telegram') }}</h1>
|
||||
</div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="row"><div class="columns" id="telegram-login" style="text-align: center;">
|
||||
<script async src="https://telegram.org/js/telegram-widget.js?22"
|
||||
data-telegram-login="{{ botLogin }}" data-size="large" data-onauth="onTelegramAuth(user)"
|
||||
data-request-access="write"></script>
|
||||
<script type="text/javascript">
|
||||
function onTelegramAuth(user) {
|
||||
window.location.href = '/oauth/telegram/callback?payload=' + encodeURIComponent(JSON.stringify(user));
|
||||
}
|
||||
</script>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -20,7 +20,7 @@
|
||||
line-height: rem($form-control-height)
|
||||
color: green
|
||||
|
||||
.autherlist
|
||||
.autherlist, .platform-list
|
||||
font-size: rem($font-size-secondary)
|
||||
|
||||
.autherlist__icon
|
||||
@@ -33,13 +33,34 @@
|
||||
padding: rem(10px 0)
|
||||
|
||||
&:not(:last-child)
|
||||
border-bottom: 1px solid #DDD
|
||||
border-bottom: 1px solid #DDD
|
||||
|
||||
input
|
||||
margin: 0
|
||||
|
||||
.platform-list__item
|
||||
margin: rem(6px 0)
|
||||
padding: rem(6px 0)
|
||||
|
||||
&:not(:first-child)
|
||||
border-top: 1px solid #DDD
|
||||
|
||||
.button
|
||||
margin: 0
|
||||
|
||||
.media__left
|
||||
width: rem(48px)
|
||||
text-align: center
|
||||
|
||||
svg
|
||||
width: rem(30px)
|
||||
height: rem(30px)
|
||||
color: #888
|
||||
fill: #888
|
||||
margin: rem(0 20px)
|
||||
|
||||
+mobile()
|
||||
.sessionlist__item
|
||||
.sessionlist__item, .platform-list__item
|
||||
.media__left
|
||||
display: none
|
||||
|
||||
|
||||
@@ -11,6 +11,59 @@ import {
|
||||
|
||||
const t = (s) => escape(i18n(s));
|
||||
|
||||
async function changeMail() {
|
||||
const changeMailDialog = new ActionDialog({
|
||||
$body: tpl`
|
||||
<div class="typo">
|
||||
<p>${i18n('Please enter your current password and new email address:')}</p>
|
||||
<label>${i18n('Current Password')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="password" name="password" data-autofocus required></input>
|
||||
</div>
|
||||
</label>
|
||||
<label>${i18n('Current Email')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="currentEmail" value="${UserContext.mail}" disabled></input>
|
||||
</div>
|
||||
</label>
|
||||
<label>${i18n('New Email')}
|
||||
<div class="textbox-container">
|
||||
<input class="textbox" type="text" name="mail" required></input>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
onDispatch(action) {
|
||||
if (action === 'ok') {
|
||||
const $password = $('[name="password"]');
|
||||
const $mail = $('[name="mail"]');
|
||||
if (!$password.val() || !$mail.val()) {
|
||||
if (!$password.val()) $password.focus();
|
||||
else $mail.focus();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}).open();
|
||||
const action = await changeMailDialog;
|
||||
if (action !== 'ok') return;
|
||||
try {
|
||||
await request.post('', {
|
||||
operation: 'change_mail',
|
||||
password: $('[name="password"]').val(),
|
||||
mail: $('[name="mail"]').val(),
|
||||
});
|
||||
} catch (err) {
|
||||
Notification.error(err.message);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
Notification.success(i18n('Successfully changed.'));
|
||||
await delay(2000);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function enableTfa() {
|
||||
const enableTFA = new ActionDialog({
|
||||
$body: tpl`
|
||||
@@ -143,4 +196,9 @@ export default new NamedPage('home_security', () => {
|
||||
if (action === 'tfa') enableTfa();
|
||||
else enableAuthn(action);
|
||||
});
|
||||
|
||||
$(document).on('click', '[data-operation="change_mail"]', async (ev) => {
|
||||
ev.preventDefault();
|
||||
await changeMail();
|
||||
});
|
||||
});
|
||||
@@ -11,43 +11,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="medium-6 columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title" id="email" data-heading>{{ _('Change Email') }}</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<form method="post">
|
||||
{{ form.form_text({
|
||||
type:'password',
|
||||
label:"SuperUser's Password" if sudoUid else 'Current Password',
|
||||
columns:10,
|
||||
name:'password',
|
||||
required:true
|
||||
}) }}
|
||||
{{ form.form_text({
|
||||
type:'text',
|
||||
label:'Current Email',
|
||||
columns:10,
|
||||
name:'currentEmail',
|
||||
value:handler.user.mail,
|
||||
disabled:true
|
||||
}) }}
|
||||
{{ form.form_text({
|
||||
type:'text',
|
||||
label:'New Email',
|
||||
columns:10,
|
||||
name:'mail',
|
||||
required:true
|
||||
}) }}
|
||||
<div class="row"><div class="columns">
|
||||
<input type="hidden" name="operation" value="change_mail">
|
||||
<input type="submit" class="rounded primary button" value="{{ _('Change Email') }}">
|
||||
</div></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="medium-6 columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
@@ -84,6 +47,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="medium-6 columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title" id="linked-accounts" data-heading>{{ _('Linked Accounts') }}</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<ul class="platform-list">
|
||||
<li class="platform-list__item">
|
||||
<div class="media">
|
||||
<div class="media__left medium">
|
||||
<span class="autherlist__icon icon icon-mail"></span>
|
||||
</div>
|
||||
<div class="media__body medium typo">
|
||||
<p>{{ _('Email Account') }}: {{ handler.user.mail }}</p>
|
||||
</div>
|
||||
<div class="media__right medium">
|
||||
<button type="button" class="rounded button" data-operation="change_mail">{{ _('Change') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<form method="post">
|
||||
<input type="hidden" name="operation" value="unlink_account">
|
||||
{%- for relation in relations -%}{% if relation.platform != 'mail' %}
|
||||
{% set platform = handler.loginMethods.find(eval("r => r.id === '" + relation.platform + "'")) %}
|
||||
<li class="platform-list__item">
|
||||
<div class="media">
|
||||
{% if platform.icon %}
|
||||
<div class="media__left medium">{{ platform.icon|safe }}</div>
|
||||
{% endif %}
|
||||
<div class="media__body medium typo">
|
||||
<p>{{ platform.name }}: {{ relation.id }}</p>
|
||||
</div>
|
||||
<div class="media__right medium">
|
||||
<button type="submit" name="platform" value="{{ relation.platform }}" class="rounded button">{{ _('Unlink') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{%- endif -%}{%- endfor -%}
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="operation" value="link_account">
|
||||
{%- for method in handler.loginMethods -%}
|
||||
{%- if not relations.some(eval("r => r.platform === '" + method.id + "'")) -%}
|
||||
<li class="platform-list__item">
|
||||
<div class="media">
|
||||
{% if method.icon %}
|
||||
<div class="media__left medium">{{ method.icon|safe }}</div>
|
||||
{% endif %}
|
||||
<div class="media__body">
|
||||
<p>{{ method.name }}</p>
|
||||
</div>
|
||||
<div class="media__right">
|
||||
<button type="submit" name="platform" value="{{ method.id }}" class="rounded button">{{ _('Link') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
</form>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row nojs--hide">
|
||||
<div class="columns">
|
||||
|
||||
Reference in new issue
Block a user