Compare commits
2 Commits
7b4d2e789f
...
6cc102cfc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc102cfc1 | |||
| 32746756d4 |
12
Dockerfile
Normal file
12
Dockerfile
Normal 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"]
|
||||
@@ -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"
|
||||
SET "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
|
||||
email String @unique
|
||||
|
||||
/// User's preferred username from Keycloak
|
||||
username String?
|
||||
/// User's preferred app handle used for discovery
|
||||
username String @unique
|
||||
|
||||
/// URL to user's profile picture
|
||||
picture String?
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('AuthService', () => {
|
||||
},
|
||||
user: {
|
||||
update: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -424,5 +425,89 @@ describe('AuthService', () => {
|
||||
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,
|
||||
isLoopbackRedirect,
|
||||
normalizeEmail,
|
||||
normalizeUsername,
|
||||
randomOpaqueToken,
|
||||
sha256,
|
||||
usernameFromEmail,
|
||||
} from './auth.utils';
|
||||
import type { SsoProvider } from './dto/sso-provider';
|
||||
|
||||
@@ -220,6 +222,11 @@ export class AuthService {
|
||||
const normalizedProviderEmail = profile.email
|
||||
? normalizeEmail(profile.email)
|
||||
: null;
|
||||
const resolvedUsername = await this.resolveUsername(
|
||||
profile.username,
|
||||
normalizedProviderEmail,
|
||||
existingIdentity.user.id,
|
||||
);
|
||||
|
||||
await this.prisma.authIdentity.update({
|
||||
where: { id: existingIdentity.id },
|
||||
@@ -228,7 +235,7 @@ export class AuthService {
|
||||
? { providerEmail: normalizedProviderEmail }
|
||||
: {}),
|
||||
providerName: profile.displayName,
|
||||
providerUsername: profile.username,
|
||||
providerUsername: resolvedUsername,
|
||||
providerPicture: profile.picture,
|
||||
emailVerified: profile.emailVerified,
|
||||
},
|
||||
@@ -241,7 +248,7 @@ export class AuthService {
|
||||
? { email: normalizedProviderEmail }
|
||||
: {}),
|
||||
name: profile.displayName,
|
||||
username: profile.username,
|
||||
username: resolvedUsername,
|
||||
picture: profile.picture,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
@@ -255,6 +262,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const email = normalizeEmail(profile.email);
|
||||
const resolvedUsername = await this.resolveUsername(
|
||||
profile.username,
|
||||
email,
|
||||
);
|
||||
|
||||
if (!profile.emailVerified) {
|
||||
throw new BadRequestException(
|
||||
@@ -277,7 +288,7 @@ export class AuthService {
|
||||
data: {
|
||||
email,
|
||||
name: profile.displayName,
|
||||
username: profile.username,
|
||||
username: resolvedUsername,
|
||||
picture: profile.picture,
|
||||
roles: [],
|
||||
lastLoginAt: now,
|
||||
@@ -291,7 +302,7 @@ export class AuthService {
|
||||
providerSubject: profile.providerSubject,
|
||||
providerEmail: email,
|
||||
providerName: profile.displayName,
|
||||
providerUsername: profile.username,
|
||||
providerUsername: resolvedUsername,
|
||||
providerPicture: profile.picture,
|
||||
emailVerified: profile.emailVerified,
|
||||
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(
|
||||
userId: string,
|
||||
email: string,
|
||||
|
||||
@@ -28,3 +28,19 @@ export function asProviderName(value: SsoProvider): 'GOOGLE' | 'DISCORD' {
|
||||
export function normalizeEmail(email: string): string {
|
||||
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