auto-populate missing username field with email local-part
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- This migration was generated locally but superseded before it was applied.
|
||||||
|
-- It remains as a no-op to preserve Prisma migration history.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
UPDATE users
|
||||||
|
SET username = lower(split_part(email, '@', 1))
|
||||||
|
WHERE username IS NULL OR btrim(username) = '';
|
||||||
|
|
||||||
|
WITH ranked_users AS (
|
||||||
|
SELECT id,
|
||||||
|
username,
|
||||||
|
row_number() OVER (PARTITION BY username ORDER BY created_at, id) AS rn
|
||||||
|
FROM users
|
||||||
|
),
|
||||||
|
deduplicated AS (
|
||||||
|
SELECT id,
|
||||||
|
CASE
|
||||||
|
WHEN rn = 1 THEN username
|
||||||
|
ELSE left(username, greatest(1, 24 - char_length(rn::text))) || rn::text
|
||||||
|
END AS next_username
|
||||||
|
FROM ranked_users
|
||||||
|
)
|
||||||
|
UPDATE users u
|
||||||
|
SET username = d.next_username
|
||||||
|
FROM deduplicated d
|
||||||
|
WHERE u.id = d.id;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN username SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX users_username_key ON users (username);
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (
|
||||||
|
SELECT LOWER(TRIM("email")) AS normalized_email
|
||||||
|
FROM "users"
|
||||||
|
GROUP BY LOWER(TRIM("email"))
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) duplicates
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Cannot normalize user emails: duplicate values would conflict after lowercasing/trimming';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
UPDATE "users"
|
UPDATE "users"
|
||||||
SET "email" = LOWER(TRIM("email"))
|
SET "email" = LOWER(TRIM("email"))
|
||||||
WHERE "email" <> LOWER(TRIM("email"));
|
WHERE "email" <> LOWER(TRIM("email"));
|
||||||
|
|
||||||
ALTER TABLE "users"
|
|
||||||
DROP CONSTRAINT IF EXISTS "users_email_key";
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "users_email_key" ON "users"(LOWER("email"));
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ model User {
|
|||||||
/// User's email address
|
/// User's email address
|
||||||
email String @unique
|
email String @unique
|
||||||
|
|
||||||
/// User's preferred username from Keycloak
|
/// User's preferred app handle used for discovery
|
||||||
username String?
|
username String @unique
|
||||||
|
|
||||||
/// URL to user's profile picture
|
/// URL to user's profile picture
|
||||||
picture String?
|
picture String?
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('AuthService', () => {
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -424,5 +425,89 @@ describe('AuthService', () => {
|
|||||||
data: expect.objectContaining({ providerEmail: 'jane@example.com' }),
|
data: expect.objectContaining({ providerEmail: 'jane@example.com' }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('derives username from email local-part when provider username is missing', async () => {
|
||||||
|
const state = service.startSso(
|
||||||
|
'google',
|
||||||
|
'http://127.0.0.1:43123/callback',
|
||||||
|
).state;
|
||||||
|
const profileWithoutUsername: SocialAuthProfile = {
|
||||||
|
...socialProfile,
|
||||||
|
email: 'Alice@example.com',
|
||||||
|
username: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
||||||
|
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.user.findFirst = jest.fn().mockResolvedValue(null);
|
||||||
|
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||||
|
Promise.resolve(
|
||||||
|
callback({
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
create: txUserCreate,
|
||||||
|
},
|
||||||
|
authIdentity: {
|
||||||
|
create: txIdentityCreate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||||
|
id: 'code-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.completeSso('google', state, profileWithoutUsername);
|
||||||
|
|
||||||
|
expect(txUserCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ username: 'alice' }),
|
||||||
|
});
|
||||||
|
expect(txIdentityCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ providerUsername: 'alice' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a numeric suffix when derived username is already taken', async () => {
|
||||||
|
const state = service.startSso(
|
||||||
|
'google',
|
||||||
|
'http://127.0.0.1:43123/callback',
|
||||||
|
).state;
|
||||||
|
const profileWithoutUsername: SocialAuthProfile = {
|
||||||
|
...socialProfile,
|
||||||
|
email: 'Alice@example.com',
|
||||||
|
username: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
||||||
|
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
||||||
|
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.user.findFirst = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'existing-user' })
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||||
|
Promise.resolve(
|
||||||
|
callback({
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
create: txUserCreate,
|
||||||
|
},
|
||||||
|
authIdentity: {
|
||||||
|
create: txIdentityCreate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||||
|
id: 'code-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.completeSso('google', state, profileWithoutUsername);
|
||||||
|
|
||||||
|
expect(txUserCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ username: 'alice2' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ import {
|
|||||||
asProviderName,
|
asProviderName,
|
||||||
isLoopbackRedirect,
|
isLoopbackRedirect,
|
||||||
normalizeEmail,
|
normalizeEmail,
|
||||||
|
normalizeUsername,
|
||||||
randomOpaqueToken,
|
randomOpaqueToken,
|
||||||
sha256,
|
sha256,
|
||||||
|
usernameFromEmail,
|
||||||
} from './auth.utils';
|
} from './auth.utils';
|
||||||
import type { SsoProvider } from './dto/sso-provider';
|
import type { SsoProvider } from './dto/sso-provider';
|
||||||
|
|
||||||
@@ -220,6 +222,11 @@ export class AuthService {
|
|||||||
const normalizedProviderEmail = profile.email
|
const normalizedProviderEmail = profile.email
|
||||||
? normalizeEmail(profile.email)
|
? normalizeEmail(profile.email)
|
||||||
: null;
|
: null;
|
||||||
|
const resolvedUsername = await this.resolveUsername(
|
||||||
|
profile.username,
|
||||||
|
normalizedProviderEmail,
|
||||||
|
existingIdentity.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
await this.prisma.authIdentity.update({
|
await this.prisma.authIdentity.update({
|
||||||
where: { id: existingIdentity.id },
|
where: { id: existingIdentity.id },
|
||||||
@@ -228,7 +235,7 @@ export class AuthService {
|
|||||||
? { providerEmail: normalizedProviderEmail }
|
? { providerEmail: normalizedProviderEmail }
|
||||||
: {}),
|
: {}),
|
||||||
providerName: profile.displayName,
|
providerName: profile.displayName,
|
||||||
providerUsername: profile.username,
|
providerUsername: resolvedUsername,
|
||||||
providerPicture: profile.picture,
|
providerPicture: profile.picture,
|
||||||
emailVerified: profile.emailVerified,
|
emailVerified: profile.emailVerified,
|
||||||
},
|
},
|
||||||
@@ -241,7 +248,7 @@ export class AuthService {
|
|||||||
? { email: normalizedProviderEmail }
|
? { email: normalizedProviderEmail }
|
||||||
: {}),
|
: {}),
|
||||||
name: profile.displayName,
|
name: profile.displayName,
|
||||||
username: profile.username,
|
username: resolvedUsername,
|
||||||
picture: profile.picture,
|
picture: profile.picture,
|
||||||
lastLoginAt: now,
|
lastLoginAt: now,
|
||||||
},
|
},
|
||||||
@@ -255,6 +262,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const email = normalizeEmail(profile.email);
|
const email = normalizeEmail(profile.email);
|
||||||
|
const resolvedUsername = await this.resolveUsername(
|
||||||
|
profile.username,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
|
||||||
if (!profile.emailVerified) {
|
if (!profile.emailVerified) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -277,7 +288,7 @@ export class AuthService {
|
|||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
name: profile.displayName,
|
name: profile.displayName,
|
||||||
username: profile.username,
|
username: resolvedUsername,
|
||||||
picture: profile.picture,
|
picture: profile.picture,
|
||||||
roles: [],
|
roles: [],
|
||||||
lastLoginAt: now,
|
lastLoginAt: now,
|
||||||
@@ -291,7 +302,7 @@ export class AuthService {
|
|||||||
providerSubject: profile.providerSubject,
|
providerSubject: profile.providerSubject,
|
||||||
providerEmail: email,
|
providerEmail: email,
|
||||||
providerName: profile.displayName,
|
providerName: profile.displayName,
|
||||||
providerUsername: profile.username,
|
providerUsername: resolvedUsername,
|
||||||
providerPicture: profile.picture,
|
providerPicture: profile.picture,
|
||||||
emailVerified: profile.emailVerified,
|
emailVerified: profile.emailVerified,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -302,6 +313,58 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveUsername(
|
||||||
|
providerUsername: string | undefined,
|
||||||
|
email: string | null,
|
||||||
|
excludeUserId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const candidates = [
|
||||||
|
providerUsername ? normalizeUsername(providerUsername) : '',
|
||||||
|
email ? usernameFromEmail(email) : '',
|
||||||
|
'friendoll',
|
||||||
|
].filter(
|
||||||
|
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const base of candidates) {
|
||||||
|
const available = await this.isUsernameAvailable(base, excludeUserId);
|
||||||
|
if (available) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||||
|
const maxBaseLength = Math.max(1, 24 - suffix.toString().length);
|
||||||
|
const candidate = `${base.slice(0, maxBaseLength)}${suffix}`;
|
||||||
|
const available = await this.isUsernameAvailable(
|
||||||
|
candidate,
|
||||||
|
excludeUserId,
|
||||||
|
);
|
||||||
|
if (available) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ServiceUnavailableException('Unable to assign a unique username');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isUsernameAvailable(
|
||||||
|
username: string,
|
||||||
|
excludeUserId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const existing = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
...(excludeUserId ? { id: { not: excludeUserId } } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return !existing;
|
||||||
|
}
|
||||||
|
|
||||||
private async issueTokens(
|
private async issueTokens(
|
||||||
userId: string,
|
userId: string,
|
||||||
email: string,
|
email: string,
|
||||||
|
|||||||
@@ -28,3 +28,19 @@ export function asProviderName(value: SsoProvider): 'GOOGLE' | 'DISCORD' {
|
|||||||
export function normalizeEmail(email: string): string {
|
export function normalizeEmail(email: string): string {
|
||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeUsername(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usernameFromEmail(email: string): string {
|
||||||
|
const localPart = normalizeEmail(email).split('@')[0] ?? '';
|
||||||
|
return normalizeUsername(localPart);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user