diff --git a/src/auth.ts b/src/auth.ts index b282678..fe5d360 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,4 @@ -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' +import { createRemoteJWKSet, jwtVerify, SignJWT, decodeJwt, type JWTPayload } from 'jose' import { GraphQLError } from 'graphql' import type { Request } from 'express' import { prisma } from './db.js' @@ -6,6 +6,8 @@ import { prisma } from './db.js' const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks' const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc' const LOGTO_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.optovia.ru' +const MANAGER_JWT_ISSUER = 'optovia:teams' +const MANAGER_JWT_AUDIENCES = ['https://teams.optovia.ru', 'https://orders.optovia.ru', 'https://logistics.optovia.ru'] const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL)) @@ -44,6 +46,41 @@ function scopesFromPayload(payload: JWTPayload): string[] { export async function publicContext(): Promise { return { scopes: [] } } +function managerJwtSecret(): Uint8Array { + const secret = process.env.MANAGER_JWT_SECRET + if (!secret) throw new GraphQLError('MANAGER_JWT_SECRET is required', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }) + return new TextEncoder().encode(secret) +} + +export async function issueManagerJwt(input: { userId: string; teamUuid?: string | null }): Promise { + return new SignJWT({ + scope: 'manager', + role: 'manager', + team_uuid: input.teamUuid ?? undefined, + }) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuer(MANAGER_JWT_ISSUER) + .setAudience(MANAGER_JWT_AUDIENCES) + .setSubject(input.userId) + .setIssuedAt() + .setExpirationTime(`${Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10)}d`) + .sign(managerJwtSecret()) +} + +async function verifyManagerJwt(token: string, audience: string = LOGTO_TEAMS_AUDIENCE): Promise { + const { payload } = await jwtVerify(token, managerJwtSecret(), { issuer: MANAGER_JWT_ISSUER, audience }) + const scopes = scopesFromPayload(payload) + const role = (payload as Record).role + if (!scopes.includes('manager') || role !== 'manager' || !payload.sub) { + throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }) + } + return { + userId: payload.sub, + teamUuid: (payload as Record).team_uuid as string | undefined, + scopes, + } +} + export async function userContext(req: Request): Promise { const token = optionalBearerToken(req) if (token === null) return { scopes: [] } @@ -54,10 +91,18 @@ export async function userContext(req: Request): Promise { } return { userId: session.user.username, sessionToken: token, scopes: ['teams:user'] } } + const unverifiedPayload = decodeJwt(token) + if (unverifiedPayload.iss === MANAGER_JWT_ISSUER) return verifyManagerJwt(token) const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }) return { userId: payload.sub, scopes: [] } } +export async function managerContext(req: Request): Promise { + const token = optionalBearerToken(req) + if (token === null) return { scopes: [] } + return verifyManagerJwt(token) +} + export async function teamContext(req: Request): Promise { const token = getBearerToken(req) const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE }) diff --git a/src/index.ts b/src/index.ts index 6e89bc4..1ad41e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,9 @@ import * as Sentry from '@sentry/node' import { publicTypeDefs, publicResolvers } from './schemas/public.js' import { userTypeDefs, userResolvers } from './schemas/user.js' import { teamTypeDefs, teamResolvers } from './schemas/team.js' +import { managerTypeDefs, managerResolvers } from './schemas/manager.js' import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js' -import { publicContext, userContext, teamContext, m2mContext, type AuthContext } from './auth.js' +import { publicContext, userContext, teamContext, managerContext, m2mContext, type AuthContext } from './auth.js' const PORT = parseInt(process.env.PORT || '8000', 10) const SENTRY_DSN = process.env.SENTRY_DSN || '' @@ -27,13 +28,15 @@ app.use(cors({ origin: ['https://optovia.ru'], credentials: true })) const publicServer = new ApolloServer({ typeDefs: publicTypeDefs, resolvers: publicResolvers, introspection: true }) const userServer = new ApolloServer({ typeDefs: userTypeDefs, resolvers: userResolvers, introspection: true }) const teamServer = new ApolloServer({ typeDefs: teamTypeDefs, resolvers: teamResolvers, introspection: true }) +const managerServer = new ApolloServer({ typeDefs: managerTypeDefs, resolvers: managerResolvers, introspection: true }) const m2mServer = new ApolloServer({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true }) -await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), m2mServer.start()]) +await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), managerServer.start(), m2mServer.start()]) app.use('/graphql/public', express.json(), expressMiddleware(publicServer, { context: async () => publicContext() }) as unknown as express.RequestHandler) app.use('/graphql/user', express.json(), expressMiddleware(userServer, { context: async ({ req }) => userContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler) app.use('/graphql/team', express.json(), expressMiddleware(teamServer, { context: async ({ req }) => teamContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler) +app.use('/graphql/manager', express.json(), expressMiddleware(managerServer, { context: async ({ req }) => managerContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler) app.use('/graphql/m2m', express.json(), expressMiddleware(m2mServer, { context: async () => m2mContext() }) as unknown as express.RequestHandler) app.get('/health', (_, res) => { res.json({ status: 'ok' }) }) @@ -43,5 +46,6 @@ app.listen(PORT, '0.0.0.0', () => { console.log(` /graphql/public - public`) console.log(` /graphql/user - id token auth`) console.log(` /graphql/team - team access token auth`) + console.log(` /graphql/manager - manager JWT auth`) console.log(` /graphql/m2m - internal services (no auth)`) }) diff --git a/src/schemas/manager.ts b/src/schemas/manager.ts new file mode 100644 index 0000000..25a2ddd --- /dev/null +++ b/src/schemas/manager.ts @@ -0,0 +1,155 @@ +import { GraphQLError } from 'graphql' +import { prisma } from '../db.js' +import { issueManagerJwt, requireScopes, type AuthContext } from '../auth.js' + +const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10) + +export const managerTypeDefs = `#graphql + type ManagerUser { + id: ID! + accountId: String + displayName: String + phone: String! + companyName: String + telegramGroupTitle: String + telegramNotificationsEnabled: Boolean! + } + + type ManagerAuthUser { + id: String + phone: String + displayName: String + isManager: Boolean! + activeTeamId: String + } + + type ManagerAuthPayload { + token: String! + sessionExpiresAt: String! + user: ManagerAuthUser! + } + + input ManagerTotpLoginInput { + phone: String! + code: String! + } + + type Query { + managerUsers: [ManagerUser!]! + } + + type Mutation { + managerTotpLogin(input: ManagerTotpLoginInput!): ManagerAuthPayload! + } +` + +function normalizePhone(phone: string): string { + const normalized = phone.replace(/[^\d+]/g, '') + if (normalized.length < 5) throw new GraphQLError('Invalid phone') + return normalized +} + +function displayName(firstName: string, lastName: string, phone: string): string { + const name = `${firstName} ${lastName}`.trim() + return name.length > 0 ? name : phone +} + +async function getOrCreateProfileByPhone(phone: string) { + const logtoId = `phone:${phone}` + const existing = await prisma.userProfile.findFirst({ + where: { OR: [{ phone }, { logtoId }] }, + include: { user: true, activeTeam: true }, + }) + if (existing !== null) return existing + + const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } }) + return prisma.userProfile.create({ + data: { userId: user.id, logtoId, phone }, + include: { user: true, activeTeam: true }, + }) +} + +async function isManagerUser(userId: number): Promise { + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (user === null) return false + if (user.isStaff || user.isSuperuser) return true + const managerMembership = await prisma.teamMember.findFirst({ + where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } }, + }) + return managerMembership !== null +} + +async function requireManagerProfile(ctx: AuthContext) { + requireScopes(ctx, 'manager') + if (!ctx.userId) throw new GraphQLError('Not authenticated') + const profile = await prisma.userProfile.findUnique({ + where: { logtoId: ctx.userId }, + include: { user: true, activeTeam: true }, + }) + if (profile === null || !(await isManagerUser(profile.userId))) { + throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } }) + } + return profile +} + +export const managerResolvers = { + Query: { + managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => { + const profile = await requireManagerProfile(ctx) + const members = await prisma.teamMember.findMany({ + where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId }, + include: { user: { include: { profile: { include: { activeTeam: true } } } }, team: true }, + orderBy: { joinedAt: 'desc' }, + }) + return members.filter(member => member.user !== null).map(member => { + const user = member.user! + const memberProfile = user.profile + const phone = memberProfile?.phone ?? '' + return { + id: member.uuid, + accountId: memberProfile?.logtoId ?? user.username, + displayName: displayName(user.firstName, user.lastName, phone), + phone, + companyName: member.team.name, + telegramGroupTitle: null, + telegramNotificationsEnabled: false, + } + }) + }, + }, + + Mutation: { + managerTotpLogin: async (_: unknown, args: { input: { phone: string; code: string } }) => { + const normalizedPhone = normalizePhone(args.input.phone) + const challenge = await prisma.loginChallenge.findFirst({ + where: { + phone: normalizedPhone, + code: args.input.code, + usedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: 'desc' }, + }) + if (challenge === null) throw new GraphQLError('Invalid login code') + + await prisma.loginChallenge.update({ where: { id: challenge.id }, data: { usedAt: new Date() } }) + const profile = await getOrCreateProfileByPhone(normalizedPhone) + if (!(await isManagerUser(profile.userId))) { + throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } }) + } + const token = await issueManagerJwt({ userId: profile.logtoId, teamUuid: profile.activeTeam?.uuid ?? null }) + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000) + return { + token, + sessionExpiresAt: expiresAt.toISOString(), + user: { + id: profile.logtoId, + phone: profile.phone, + displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone), + isManager: true, + activeTeamId: profile.activeTeam?.uuid ?? null, + }, + } + }, + }, +} diff --git a/src/schemas/user.ts b/src/schemas/user.ts index dbf52bf..a53b791 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -43,16 +43,6 @@ export const userTypeDefs = `#graphql user: User! } - type ManagerUser { - id: ID! - accountId: String - displayName: String - phone: String! - companyName: String - telegramGroupTitle: String - telegramNotificationsEnabled: Boolean! - } - input RequestLoginOtpInput { phone: String! } @@ -62,11 +52,6 @@ export const userTypeDefs = `#graphql code: String! } - input ManagerTotpLoginInput { - phone: String! - code: String! - } - type TeamMemberInfo { uuid: String! role: String! @@ -118,14 +103,12 @@ export const userTypeDefs = `#graphql type Query { me: User - managerUsers: [ManagerUser!]! getTeam(teamId: String!): TeamWithMembers } type Mutation { requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge! verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload! - managerTotpLogin(input: ManagerTotpLoginInput!): AuthPayload! logout: Boolean! createTeam(input: CreateTeamInput!): CreateTeamResult updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult @@ -236,31 +219,6 @@ export const userResolvers = { return mapProfileUser(profile) }, - managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => { - if (!ctx.userId) throw new GraphQLError('Not authenticated') - const profile = await getOrCreateProfile(ctx.userId) - if (!(await isManagerUser(profile.userId))) return [] - const members = await prisma.teamMember.findMany({ - where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId }, - include: { user: { include: { profile: { include: { activeTeam: true } } } }, team: true }, - orderBy: { joinedAt: 'desc' }, - }) - return members.filter(member => member.user !== null).map(member => { - const user = member.user! - const memberProfile = user.profile - const phone = memberProfile?.phone ?? '' - return { - id: member.uuid, - accountId: memberProfile?.logtoId ?? user.username, - displayName: displayName(user.firstName, user.lastName, phone), - phone, - companyName: member.team.name, - telegramGroupTitle: null, - telegramNotificationsEnabled: false, - } - }) - }, - getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const team = await prisma.team.findUnique({ @@ -302,10 +260,6 @@ export const userResolvers = { return verifyPhoneLogin(args.input.phone, args.input.code) }, - managerTotpLogin: async (_: unknown, args: { input: { phone: string; code: string } }) => { - return verifyPhoneLogin(args.input.phone, args.input.code) - }, - logout: async (_: unknown, __: unknown, ctx: AuthContext) => { if (ctx.sessionToken !== undefined) { await prisma.authSession.updateMany({ where: { token: ctx.sessionToken, revokedAt: null }, data: { revokedAt: new Date() } })