Compare commits

..

2 Commits

Author SHA1 Message Date
6cc102cfc1 dockerfile 2026-03-18 14:13:02 +08:00
32746756d4 auto-populate missing username field with email local-part 2026-03-17 19:27:41 +08:00
8 changed files with 227 additions and 11 deletions

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm i -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/src/main.js"]

View File

@@ -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.

View File

@@ -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);

View File

@@ -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"));

View File

@@ -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?

View File

@@ -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' }),
});
});
}); });
}); });

View File

@@ -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,

View File

@@ -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);
}