mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Automatically register user when connecting with OIDC profile not already existing
This commit is contained in:
parent
70a669a29c
commit
99f14ba114
|
|
@ -48,11 +48,12 @@ rules = [
|
|||
jwt_key = ""
|
||||
|
||||
# If enabled, Lysand will require users to log in with an OAuth provider
|
||||
# Note that registering with an OAuth provider is not supported yet, so
|
||||
# this will lock out users who are not already registered or who do not have
|
||||
# an OAuth account linked
|
||||
forced = false
|
||||
|
||||
# Allow registration with OAuth providers
|
||||
# Overriden by the signups.registration setting
|
||||
allow_registration = true
|
||||
|
||||
# Delete this section if you don't want to use custom OAuth providers
|
||||
# This is an example configuration
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const configValidator = z.object({
|
|||
}),
|
||||
oidc: z.object({
|
||||
forced: z.boolean().default(false),
|
||||
allow_registration: z.boolean().default(true),
|
||||
providers: z
|
||||
.array(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -136,6 +136,14 @@ export class OAuthManager {
|
|||
};
|
||||
}
|
||||
|
||||
async linkUserInDatabase(userId: string, sub: string): Promise<void> {
|
||||
await db.insert(OpenIdAccounts).values({
|
||||
serverId: sub,
|
||||
issuerId: this.issuer.id,
|
||||
userId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async linkUser(
|
||||
userId: string,
|
||||
// Return value of automaticOidcFlow
|
||||
|
|
@ -182,11 +190,7 @@ export class OAuthManager {
|
|||
}
|
||||
|
||||
// Link the account
|
||||
await db.insert(OpenIdAccounts).values({
|
||||
serverId: userInfo.sub,
|
||||
issuerId: this.issuer.id,
|
||||
userId: userId,
|
||||
});
|
||||
await this.linkUserInDatabase(userId, userInfo.sub);
|
||||
|
||||
return response(null, 302, {
|
||||
Location: `${config.http.base_url}${
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
header?: string;
|
||||
admin?: boolean;
|
||||
skipPasswordHash?: boolean;
|
||||
}): Promise<User | null> {
|
||||
}): Promise<User> {
|
||||
const keys = await User.generateKeys();
|
||||
|
||||
const newUser = (
|
||||
|
|
@ -472,7 +472,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
const finalUser = await User.fromId(newUser.id);
|
||||
|
||||
if (!finalUser) {
|
||||
return null;
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
|
||||
// Add to Meilisearch
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { applyConfig, auth, handleZodError, jsonOrForm } from "@/api";
|
|||
import { jsonResponse, response } from "@/response";
|
||||
import { tempmailDomains } from "@/tempmail";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Hono } from "hono";
|
||||
import ISO6391 from "iso-639-1";
|
||||
import { z } from "zod";
|
||||
|
|
@ -30,7 +30,7 @@ export const schemas = {
|
|||
form: z.object({
|
||||
username: z.string(),
|
||||
email: z.string().toLowerCase(),
|
||||
password: z.string(),
|
||||
password: z.string().optional(),
|
||||
agreement: z
|
||||
.string()
|
||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
||||
|
|
@ -153,7 +153,12 @@ export default (app: Hono) =>
|
|||
}
|
||||
|
||||
// Check if username is taken
|
||||
if (await User.fromSql(eq(Users.username, username))) {
|
||||
if (
|
||||
await User.fromSql(
|
||||
and(eq(Users.username, username)),
|
||||
isNull(Users.instanceId),
|
||||
)
|
||||
) {
|
||||
errors.details.username.push({
|
||||
error: "ERR_TAKEN",
|
||||
description: "is already taken",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { applyConfig, handleZodError } from "@/api";
|
|||
import { randomString } from "@/math";
|
||||
import { errorResponse, response } from "@/response";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Hono } from "hono";
|
||||
import { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~/database/entities/token";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions, Tokens } from "~/drizzle/schema";
|
||||
import { RolePermissions, Tokens, Users } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { OAuthManager } from "~/packages/database-interface/oauth";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
|
@ -111,7 +112,8 @@ export default (app: Hono) =>
|
|||
return userInfo;
|
||||
}
|
||||
|
||||
const { sub } = userInfo.userInfo;
|
||||
const { sub, email, preferred_username, picture } =
|
||||
userInfo.userInfo;
|
||||
const flow = userInfo.flow;
|
||||
|
||||
// If linking account
|
||||
|
|
@ -119,7 +121,7 @@ export default (app: Hono) =>
|
|||
return await manager.linkUser(user_id, userInfo);
|
||||
}
|
||||
|
||||
const userId = (
|
||||
let userId = (
|
||||
await db.query.OpenIdAccounts.findFirst({
|
||||
where: (account, { eq, and }) =>
|
||||
and(
|
||||
|
|
@ -130,16 +132,76 @@ export default (app: Hono) =>
|
|||
)?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return returnError(
|
||||
{
|
||||
redirect_uri: flow.application?.redirectUri,
|
||||
client_id: flow.application?.clientId,
|
||||
response_type: "code",
|
||||
scope: flow.application?.scopes,
|
||||
},
|
||||
"invalid_request",
|
||||
"No user found with that account",
|
||||
);
|
||||
// Register new user
|
||||
if (
|
||||
config.signups.registration &&
|
||||
config.oidc.allow_registration
|
||||
) {
|
||||
let username =
|
||||
preferred_username ??
|
||||
email?.split("@")[0] ??
|
||||
randomString(8, "hex");
|
||||
|
||||
const usernameValidator = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.min(3)
|
||||
.max(config.validation.max_username_size)
|
||||
.refine(
|
||||
(value) =>
|
||||
!config.validation.username_blacklist.includes(
|
||||
value,
|
||||
),
|
||||
)
|
||||
.refine((value) =>
|
||||
config.filters.username.some((filter) =>
|
||||
value.match(filter),
|
||||
),
|
||||
)
|
||||
.refine(
|
||||
async (value) =>
|
||||
!(await User.fromSql(
|
||||
and(
|
||||
eq(Users.username, value),
|
||||
isNull(Users.instanceId),
|
||||
),
|
||||
)),
|
||||
);
|
||||
|
||||
try {
|
||||
await usernameValidator.parseAsync(username);
|
||||
} catch {
|
||||
username = randomString(8, "hex");
|
||||
}
|
||||
|
||||
const doesEmailExist = email
|
||||
? !!(await User.fromSql(eq(Users.email, email)))
|
||||
: false;
|
||||
|
||||
// Create new user
|
||||
const user = await User.fromDataLocal({
|
||||
email: doesEmailExist ? undefined : email,
|
||||
username,
|
||||
avatar: picture,
|
||||
password: undefined,
|
||||
});
|
||||
|
||||
// Link account
|
||||
await manager.linkUserInDatabase(user.id, sub);
|
||||
|
||||
userId = user.id;
|
||||
} else {
|
||||
return returnError(
|
||||
{
|
||||
redirect_uri: flow.application?.redirectUri,
|
||||
client_id: flow.application?.clientId,
|
||||
response_type: "code",
|
||||
scope: flow.application?.scopes,
|
||||
},
|
||||
"invalid_request",
|
||||
"No user found with that account",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.fromId(userId);
|
||||
|
|
|
|||
Loading…
Reference in a new issue