fix: new oauth stuff (#240)

* - fix: use oauth's user id instead of username
 - feat: add login only config for oauth

* Addresses tomato's concerns

* fix: catch same account on different user
This commit is contained in:
TacticalCoderJay 2022-12-06 21:40:13 -08:00 committed by GitHub
parent 9f797613d2
commit ea1a0b7fc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 71 additions and 30 deletions

View file

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[provider,oauthId]` on the table `OAuth` will be added. If there are existing duplicate values, this will fail.
- Added the required column `oauthId` to the `OAuth` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");

View file

@ -100,8 +100,11 @@ model OAuth {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int userId Int
username String username String
oauthId String
token String token String
refresh String? refresh String?
@@unique([provider, oauthId])
} }
enum OauthProviders { enum OauthProviders {

View file

@ -102,6 +102,7 @@ export interface ConfigFeatures {
invites_length: number; invites_length: number;
oauth_registration: boolean; oauth_registration: boolean;
oauth_login_only: boolean;
user_registration: boolean; user_registration: boolean;
headless: boolean; headless: boolean;

View file

@ -137,6 +137,7 @@ export default function readConfig() {
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'), map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'), map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
map('FEATURES_OAUTH_LOGIN_ONLY', 'boolean', 'features.oauth_login_only'),
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'), map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
map('FEATURES_HEADLESS', 'boolean', 'features.headless'), map('FEATURES_HEADLESS', 'boolean', 'features.headless'),

View file

@ -167,6 +167,7 @@ const validator = s.object({
invites: s.boolean.default(false), invites: s.boolean.default(false),
invites_length: s.number.default(6), invites_length: s.number.default(6),
oauth_registration: s.boolean.default(false), oauth_registration: s.boolean.default(false),
oauth_login_only: s.boolean.default(false),
user_registration: s.boolean.default(false), user_registration: s.boolean.default(false),
headless: s.boolean.default(false), headless: s.boolean.default(false),
}) })
@ -174,6 +175,7 @@ const validator = s.object({
invites: false, invites: false,
invites_length: 6, invites_length: 6,
oauth_registration: false, oauth_registration: false,
oauth_login_only: false,
user_registration: false, user_registration: false,
headless: false, headless: false,
}), }),

View file

@ -13,6 +13,7 @@ export interface OAuthQuery {
export interface OAuthResponse { export interface OAuthResponse {
username?: string; username?: string;
user_id?: string;
access_token?: string; access_token?: string;
refresh_token?: string; refresh_token?: string;
avatar?: string; avatar?: string;
@ -55,23 +56,27 @@ export const withOAuth =
const { state } = req.query as { state?: string }; const { state } = req.query as { state?: string };
const existing = await prisma.user.findFirst({ const existingOauth = await prisma.oAuth.findUnique({
where: { where: {
oauth: { provider_oauthId: {
some: { provider: provider.toUpperCase() as OauthProviders,
provider: provider.toUpperCase() as OauthProviders, oauthId: oauth_resp.user_id,
username: oauth_resp.username,
},
}, },
}, },
include: { });
oauth: true,
const existingUser = await prisma.user.findFirst({
where: {
username: oauth_resp.username,
},
select: {
username: true,
id: true,
}, },
}); });
const user = await req.user(); const user = await req.user();
const existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
const userOauth = user?.oauth?.find((o) => o.provider === provider.toUpperCase()); const userOauth = user?.oauth?.find((o) => o.provider === provider.toUpperCase());
if (state === 'link') { if (state === 'link') {
@ -81,22 +86,30 @@ export const withOAuth =
return oauthError(`This account was already linked with ${provider}!`); return oauthError(`This account was already linked with ${provider}!`);
logger.debug(`attempting to link ${provider} account to ${user.username}`); logger.debug(`attempting to link ${provider} account to ${user.username}`);
await prisma.user.update({ try {
where: { await prisma.user.update({
id: user.id, where: {
}, id: user.id,
data: {
oauth: {
create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
},
}, },
avatar: oauth_resp.avatar, data: {
}, oauth: {
}); create: {
provider: OauthProviders[provider.toUpperCase()],
token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username,
oauthId: oauth_resp.user_id,
},
},
avatar: oauth_resp.avatar,
},
});
} catch (e) {
if (e.code === 'P2002') {
logger.debug(`account already linked with ${provider}`);
return oauthError('This account is already linked with another user.');
} else throw e;
}
res.setUserCookie(user.id); res.setUserCookie(user.id);
logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`); logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
@ -112,6 +125,7 @@ export const withOAuth =
token: oauth_resp.access_token, token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null, refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username, username: oauth_resp.username,
oauthId: oauth_resp.user_id,
}, },
}); });
@ -119,7 +133,7 @@ export const withOAuth =
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`); logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard'); return res.redirect('/dashboard');
} else if (existing && existingOauth) { } else if (existingOauth) {
await prisma.oAuth.update({ await prisma.oAuth.update({
where: { where: {
id: existingOauth!.id, id: existingOauth!.id,
@ -128,16 +142,20 @@ export const withOAuth =
token: oauth_resp.access_token, token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null, refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username, username: oauth_resp.username,
oauthId: oauth_resp.user_id,
}, },
}); });
res.setUserCookie(existing.id); res.setUserCookie(existingOauth.userId);
Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(${provider})`); Logger.get('user').info(
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`
);
return res.redirect('/dashboard'); return res.redirect('/dashboard');
} else if (existing) { } else if (config.features.oauth_login_only) {
return oauthError(`Username "${oauth_resp.username}" is already taken, unable to create account.`); return oauthError('Login only mode is enabled, unable to create account.');
} } else if (existingUser)
return oauthError(`Username ${oauth_resp.username} is already taken, unable to create account.`);
logger.debug('creating new user via oauth'); logger.debug('creating new user via oauth');
const nuser = await prisma.user.create({ const nuser = await prisma.user.create({
@ -150,6 +168,7 @@ export const withOAuth =
token: oauth_resp.access_token, token: oauth_resp.access_token,
refresh: oauth_resp.refresh_token || null, refresh: oauth_resp.refresh_token || null,
username: oauth_resp.username, username: oauth_resp.username,
oauthId: oauth_resp.user_id,
}, },
}, },
avatar: oauth_resp.avatar, avatar: oauth_resp.avatar,

View file

@ -69,6 +69,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
return { return {
username: userJson.username, username: userJson.username,
user_id: userJson.id,
avatar: avatarBase64, avatar: avatarBase64,
access_token: json.access_token, access_token: json.access_token,
refresh_token: json.refresh_token, refresh_token: json.refresh_token,

View file

@ -56,6 +56,7 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
return { return {
username: userJson.login, username: userJson.login,
user_id: userJson.id.toString(),
avatar: avatarBase64, avatar: avatarBase64,
access_token: json.access_token, access_token: json.access_token,
}; };

View file

@ -61,6 +61,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
return { return {
username: userJson.names[0].displayName, username: userJson.names[0].displayName,
user_id: userJson.resourceName.split('/')[1],
avatar: avatarBase64, avatar: avatarBase64,
access_token: json.access_token, access_token: json.access_token,
refresh_token: json.refresh_token, refresh_token: json.refresh_token,