feat(api): allow API key authentication for domain endpoints
Switch /domains controller from `isAuthenticated` (cookie-only) to `requireAuth` (cookie OR API key), matching the pattern used by other project-scoped API endpoints (/v1/send, /contacts, etc.). API keys are project-scoped credentials with full access; for write operations the projectId in the request must equal the API key's projectId. JWT (dashboard) auth retains role-based checks (requireAdminAccess for POST/DELETE). Also: improve UX when a domain is already linked to the same project by returning a clear error instead of the generic "linked to another project" message. Refactor DomainService.checkDomainOwnership to make `userId` optional (needed for API key path) while preserving its existing return shape and adding `projectId` to the result.
This commit is contained in:
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
|
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {NotAllowed, NotFound} from '../exceptions/index.js';
|
import {NotAllowed, NotFound} from '../exceptions/index.js';
|
||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
import {MembershipService} from '../services/MembershipService.js';
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
@@ -17,14 +17,19 @@ export class Domains {
|
|||||||
* Get all domains for a project
|
* Get all domains for a project
|
||||||
*/
|
*/
|
||||||
@Get('project/:projectId')
|
@Get('project/:projectId')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
if (auth.type === 'apiKey') {
|
||||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
if (auth.projectId !== projectId) {
|
||||||
|
throw new NotAllowed('You do not have access to this project');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
const domains = await DomainService.getProjectDomains(projectId);
|
const domains = await DomainService.getProjectDomains(projectId);
|
||||||
|
|
||||||
@@ -35,19 +40,23 @@ export class Domains {
|
|||||||
* Add a new domain to a project
|
* Add a new domain to a project
|
||||||
*/
|
*/
|
||||||
@Post('')
|
@Post('')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
const {projectId: requestedProjectId, domain} = DomainSchemas.create.parse(req.body);
|
||||||
|
const projectId = auth.type === 'apiKey' ? auth.projectId : requestedProjectId;
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (auth.type === 'apiKey') {
|
||||||
|
if (requestedProjectId !== auth.projectId) {
|
||||||
|
throw new NotAllowed('You do not have access to this project');
|
||||||
|
}
|
||||||
|
} else if (!auth.userId) {
|
||||||
throw new NotFound('User authentication required');
|
throw new NotFound('User authentication required');
|
||||||
|
} else {
|
||||||
|
await MembershipService.requireAdminAccess(auth.userId, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to this project
|
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
|
||||||
|
|
||||||
// Block domain changes on disabled projects
|
// Block domain changes on disabled projects
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
@@ -68,6 +77,12 @@ export class Domains {
|
|||||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||||
|
|
||||||
if (ownershipCheck.exists) {
|
if (ownershipCheck.exists) {
|
||||||
|
if (ownershipCheck.projectId === projectId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'This domain is already linked to this project.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If domain exists and user is a member of that project, allow it
|
// If domain exists and user is a member of that project, allow it
|
||||||
if (ownershipCheck.isMember) {
|
if (ownershipCheck.isMember) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -99,7 +114,7 @@ export class Domains {
|
|||||||
* Check verification status for a domain
|
* Check verification status for a domain
|
||||||
*/
|
*/
|
||||||
@Get(':id/verify')
|
@Get(':id/verify')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
@@ -111,8 +126,13 @@ export class Domains {
|
|||||||
throw new NotFound('Domain not found');
|
throw new NotFound('Domain not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to the project this domain belongs to
|
if (auth.type === 'apiKey') {
|
||||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
if (auth.projectId !== domain.projectId) {
|
||||||
|
throw new NotAllowed('You do not have access to this project');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
const verificationStatus = await DomainService.checkVerification(id);
|
const verificationStatus = await DomainService.checkVerification(id);
|
||||||
|
|
||||||
@@ -127,7 +147,7 @@ export class Domains {
|
|||||||
* Remove a domain from a project
|
* Remove a domain from a project
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
@@ -139,8 +159,13 @@ export class Domains {
|
|||||||
throw new NotFound('Domain not found');
|
throw new NotFound('Domain not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to the project this domain belongs to
|
if (auth.type === 'apiKey') {
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
if (auth.projectId !== domain.projectId) {
|
||||||
|
throw new NotAllowed('You do not have access to this project');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Block domain changes on disabled projects
|
// Block domain changes on disabled projects
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
||||||
|
|||||||
@@ -438,15 +438,14 @@ export class DomainService {
|
|||||||
* @param userId User ID to check membership
|
* @param userId User ID to check membership
|
||||||
* @returns Object with exists flag and membership info
|
* @returns Object with exists flag and membership info
|
||||||
*/
|
*/
|
||||||
public static async checkDomainOwnership(domain: string, userId: string) {
|
public static async checkDomainOwnership(domain: string, userId?: string) {
|
||||||
const existingDomain = await prisma.domain.findFirst({
|
const existingDomain = await prisma.domain.findFirst({
|
||||||
where: {domain},
|
where: {domain},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
include: {
|
select: {
|
||||||
members: {
|
id: true,
|
||||||
where: {userId},
|
name: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -456,8 +455,20 @@ export class DomainService {
|
|||||||
return {exists: false};
|
return {exists: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a member of the project that owns this domain
|
let isMember = false;
|
||||||
const isMember = existingDomain.project.members.length > 0;
|
|
||||||
|
if (userId) {
|
||||||
|
const membership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId: existingDomain.project.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
isMember = membership !== null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user