Compare commits

...

1 Commits

Author SHA1 Message Date
Ruslan Bakiev
e6e014ebb0 Add Flutter auth sessions to teams
All checks were successful
Build Docker Image / build (push) Successful in 2m10s
2026-05-31 16:46:04 +05:00
4 changed files with 328 additions and 66 deletions

View File

@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "teams_app_loginchallenge" (
"id" SERIAL NOT NULL,
"uuid" TEXT NOT NULL,
"phone" VARCHAR(50) NOT NULL,
"code" VARCHAR(20) NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "teams_app_loginchallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "teams_app_authsession" (
"id" SERIAL NOT NULL,
"token" VARCHAR(255) NOT NULL,
"user_id" INTEGER NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "teams_app_authsession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "teams_app_loginchallenge_uuid_key" ON "teams_app_loginchallenge"("uuid");
-- CreateIndex
CREATE INDEX "teams_app_loginchallenge_phone_idx" ON "teams_app_loginchallenge"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "teams_app_authsession_token_key" ON "teams_app_authsession"("token");
-- CreateIndex
CREATE INDEX "teams_app_authsession_user_id_idx" ON "teams_app_authsession"("user_id");
-- AddForeignKey
ALTER TABLE "teams_app_authsession" ADD CONSTRAINT "teams_app_authsession_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -8,20 +8,20 @@ datasource db {
} }
model Team { model Team {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(uuid()) uuid String @unique @default(uuid())
name String @db.VarChar(200) name String @db.VarChar(200)
teamType String @default("BUYER") @map("team_type") @db.VarChar(20) teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
logtoOrgId String? @map("logto_org_id") @db.VarChar(100) logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
ownerId Int? @map("owner_id") ownerId Int? @map("owner_id")
owner User? @relation(fields: [ownerId], references: [id]) owner User? @relation(fields: [ownerId], references: [id])
selectedLocationType String? @map("selected_location_type") @db.VarChar(20) selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100) selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
selectedLocationName String? @map("selected_location_name") @db.VarChar(255) selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
selectedLocationLat Float? @map("selected_location_lat") selectedLocationLat Float? @map("selected_location_lat")
selectedLocationLon Float? @map("selected_location_lon") selectedLocationLon Float? @map("selected_location_lon")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
members TeamMember[] members TeamMember[]
invitations TeamInvitation[] invitations TeamInvitation[]
@@ -32,40 +32,67 @@ model Team {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
password String @default("") @db.VarChar(128) password String @default("") @db.VarChar(128)
username String @unique @db.VarChar(150) username String @unique @db.VarChar(150)
firstName String @default("") @map("first_name") @db.VarChar(150) firstName String @default("") @map("first_name") @db.VarChar(150)
lastName String @default("") @map("last_name") @db.VarChar(150) lastName String @default("") @map("last_name") @db.VarChar(150)
email String @default("") @db.VarChar(254) email String @default("") @db.VarChar(254)
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
dateJoined DateTime @default(now()) @map("date_joined") dateJoined DateTime @default(now()) @map("date_joined")
isSuperuser Boolean @default(false) @map("is_superuser") isSuperuser Boolean @default(false) @map("is_superuser")
isStaff Boolean @default(false) @map("is_staff") isStaff Boolean @default(false) @map("is_staff")
lastLogin DateTime? @map("last_login") lastLogin DateTime? @map("last_login")
profile UserProfile? profile UserProfile?
teams Team[] sessions AuthSession[]
teams Team[]
memberships TeamMember[] memberships TeamMember[]
@@map("auth_user") @@map("auth_user")
} }
model UserProfile { model UserProfile {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int @unique @map("user_id") userId Int @unique @map("user_id")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
logtoId String @unique @map("logto_id") @db.VarChar(255) logtoId String @unique @map("logto_id") @db.VarChar(255)
avatarId String? @map("avatar_id") @db.VarChar(255) avatarId String? @map("avatar_id") @db.VarChar(255)
phone String @default("") @db.VarChar(50) phone String @default("") @db.VarChar(50)
activeTeamId Int? @map("active_team_id") activeTeamId Int? @map("active_team_id")
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id]) activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@map("teams_app_userprofile") @@map("teams_app_userprofile")
} }
model LoginChallenge {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
phone String @db.VarChar(50)
code String @db.VarChar(20)
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([phone])
@@map("teams_app_loginchallenge")
}
model AuthSession {
id Int @id @default(autoincrement())
token String @unique @db.VarChar(255)
userId Int @map("user_id")
user User @relation(fields: [userId], references: [id])
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId])
@@map("teams_app_authsession")
}
model TeamMember { model TeamMember {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(uuid()) uuid String @unique @default(uuid())
@@ -81,16 +108,16 @@ model TeamMember {
} }
model TeamInvitation { model TeamInvitation {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(uuid()) uuid String @unique @default(uuid())
teamId Int @map("team_id") teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id]) team Team @relation(fields: [teamId], references: [id])
email String @db.VarChar(254) email String @db.VarChar(254)
role String @default("MEMBER") @db.VarChar(20) role String @default("MEMBER") @db.VarChar(20)
status String @default("PENDING") @db.VarChar(20) status String @default("PENDING") @db.VarChar(20)
invitedBy String @default("") @map("invited_by") @db.VarChar(255) invitedBy String @default("") @map("invited_by") @db.VarChar(255)
expiresAt DateTime? @map("expires_at") expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
tokens TeamInvitationToken[] tokens TeamInvitationToken[]
@@ -99,14 +126,14 @@ model TeamInvitation {
} }
model TeamInvitationToken { model TeamInvitationToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(uuid()) uuid String @unique @default(uuid())
invitationId Int @map("invitation_id") invitationId Int @map("invitation_id")
invitation TeamInvitation @relation(fields: [invitationId], references: [id]) invitation TeamInvitation @relation(fields: [invitationId], references: [id])
tokenHash String @unique @map("token_hash") @db.VarChar(255) tokenHash String @unique @map("token_hash") @db.VarChar(255)
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20) workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
expiresAt DateTime? @map("expires_at") expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@map("teams_app_teaminvitationtoken") @@map("teams_app_teaminvitationtoken")
} }

View File

@@ -1,6 +1,7 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import type { Request } from 'express' import type { Request } from 'express'
import { prisma } from './db.js'
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks' 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_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
@@ -11,10 +12,13 @@ const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
export interface AuthContext { export interface AuthContext {
userId?: string userId?: string
teamUuid?: string teamUuid?: string
sessionToken?: string
scopes: string[] scopes: string[]
isM2M?: boolean isM2M?: boolean
} }
export const SESSION_TOKEN_PREFIX = 'optovia-session:'
function getBearerToken(req: Request): string { function getBearerToken(req: Request): string {
const auth = req.headers.authorization || '' const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } }) if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
@@ -23,6 +27,14 @@ function getBearerToken(req: Request): string {
return token return token
} }
function optionalBearerToken(req: Request): string | null {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) return null
const token = auth.slice(7)
if (!token || token === 'undefined') return null
return token
}
function scopesFromPayload(payload: JWTPayload): string[] { function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope const scope = payload.scope
if (!scope) return [] if (!scope) return []
@@ -33,7 +45,15 @@ function scopesFromPayload(payload: JWTPayload): string[] {
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } } export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
export async function userContext(req: Request): Promise<AuthContext> { export async function userContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req) const token = optionalBearerToken(req)
if (token === null) return { scopes: [] }
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
const session = await prisma.authSession.findUnique({ where: { token }, include: { user: true } })
if (session === null || session.revokedAt !== null || session.expiresAt <= new Date()) {
throw new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } })
}
return { userId: session.user.username, sessionToken: token, scopes: ['teams:user'] }
}
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER }) const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
return { userId: payload.sub, scopes: [] } return { userId: payload.sub, scopes: [] }
} }

View File

@@ -1,6 +1,11 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { randomBytes } from 'crypto'
import { prisma } from '../db.js' import { prisma } from '../db.js'
import type { AuthContext } from '../auth.js' import { SESSION_TOKEN_PREFIX, type AuthContext } from '../auth.js'
const OTP_TTL_MINUTES = Number.parseInt(process.env.LOGIN_OTP_TTL_MINUTES || '10', 10)
const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10)
const DEV_LOGIN_OTP_CODE = process.env.LOGIN_OTP_DEV_CODE || '000000'
export const userTypeDefs = `#graphql export const userTypeDefs = `#graphql
type UserTeam { type UserTeam {
@@ -15,13 +20,53 @@ export const userTypeDefs = `#graphql
id: String id: String
firstName: String firstName: String
lastName: String lastName: String
displayName: String
phone: String phone: String
avatarId: String avatarId: String
isAdmin: Boolean
activeTeamId: String activeTeamId: String
activeTeam: UserTeam activeTeam: UserTeam
teams: [UserTeam] teams: [UserTeam]
} }
type AuthChallenge {
challengeType: String!
phone: String!
expiresAt: String!
codeForDev: String
totpConfigured: Boolean!
}
type AuthPayload {
token: String!
sessionExpiresAt: String!
user: User!
}
type ManagerUser {
id: ID!
accountId: String
displayName: String
phone: String!
companyName: String
telegramGroupTitle: String
telegramNotificationsEnabled: Boolean!
}
input RequestLoginOtpInput {
phone: String!
}
input VerifyLoginOtpInput {
phone: String!
code: String!
}
input AdminTotpLoginInput {
phone: String!
code: String!
}
type TeamMemberInfo { type TeamMemberInfo {
uuid: String! uuid: String!
role: String! role: String!
@@ -73,10 +118,15 @@ export const userTypeDefs = `#graphql
type Query { type Query {
me: User me: User
managerUsers: [ManagerUser!]!
getTeam(teamId: String!): TeamWithMembers getTeam(teamId: String!): TeamWithMembers
} }
type Mutation { type Mutation {
requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge!
verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload!
adminTotpLogin(input: AdminTotpLoginInput!): AuthPayload!
logout: Boolean!
createTeam(input: CreateTeamInput!): CreateTeamResult createTeam(input: CreateTeamInput!): CreateTeamResult
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
switchTeam(teamId: String!): SwitchTeamResult switchTeam(teamId: String!): SwitchTeamResult
@@ -95,22 +145,119 @@ async function getOrCreateProfile(logtoId: string) {
return profile return profile
} }
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 isAdminUser(userId: number): Promise<boolean> {
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 mapProfileUser(profile: Awaited<ReturnType<typeof getOrCreateProfile>>) {
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
return {
id: profile.logtoId,
firstName: profile.user.firstName,
lastName: profile.user.lastName,
displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone),
phone: profile.phone,
avatarId: profile.avatarId,
isAdmin: await isAdminUser(profile.userId),
activeTeamId: profile.activeTeam?.uuid ?? null,
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null,
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })),
}
}
async function issueSession(userId: number) {
const token = `${SESSION_TOKEN_PREFIX}${randomBytes(32).toString('hex')}`
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000)
await prisma.authSession.create({ data: { token, userId, expiresAt } })
return { token, expiresAt }
}
async function verifyPhoneLogin(phone: string, code: string) {
const normalizedPhone = normalizePhone(phone)
const challenge = await prisma.loginChallenge.findFirst({
where: {
phone: normalizedPhone,
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)
const session = await issueSession(profile.userId)
return {
token: session.token,
sessionExpiresAt: session.expiresAt.toISOString(),
user: await mapProfileUser(profile),
}
}
export const userResolvers = { export const userResolvers = {
Query: { Query: {
me: async (_: unknown, __: unknown, ctx: AuthContext) => { me: async (_: unknown, __: unknown, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated') if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId) const profile = await getOrCreateProfile(ctx.userId)
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } }) return mapProfileUser(profile)
return { },
id: ctx.userId,
firstName: profile.user.firstName, managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => {
lastName: profile.user.lastName, if (!ctx.userId) throw new GraphQLError('Not authenticated')
phone: profile.phone, const profile = await getOrCreateProfile(ctx.userId)
avatarId: profile.avatarId, if (!(await isAdminUser(profile.userId))) return []
activeTeamId: profile.activeTeam?.uuid ?? null, const members = await prisma.teamMember.findMany({
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null, where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId },
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })), 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) => { getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
@@ -137,6 +284,34 @@ export const userResolvers = {
}, },
Mutation: { Mutation: {
requestLoginOtp: async (_: unknown, args: { input: { phone: string } }) => {
const phone = normalizePhone(args.input.phone)
const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000)
await prisma.loginChallenge.create({ data: { phone, code: DEV_LOGIN_OTP_CODE, expiresAt } })
return {
challengeType: 'otp',
phone,
expiresAt: expiresAt.toISOString(),
codeForDev: process.env.NODE_ENV === 'production' ? null : DEV_LOGIN_OTP_CODE,
totpConfigured: true,
}
},
verifyLoginOtp: async (_: unknown, args: { input: { phone: string; code: string } }) => {
return verifyPhoneLogin(args.input.phone, args.input.code)
},
adminTotpLogin: 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() } })
}
return true
},
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => { createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated') if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId) const profile = await getOrCreateProfile(ctx.userId)