First Commit
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 5m26s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-07-18 13:27:37 +07:00
commit bac95bdb2e
154 changed files with 26610 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@authportal/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
APP_IDS,
allPermissionKeys,
} from '@authportal/shared'
import {
createDb,
getUserApps,
healthCheck,
migrateSchema,
setUserAccess,
users,
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hash } from '@node-rs/argon2'
import { randomUUID } from 'node:crypto'
import type { AppConfig } from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: AppDb
sqlite: Sqlite
}
}
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
.values({
id,
email: app.config.adminEmail.toLowerCase(),
name: app.config.adminName,
passwordHash,
isAdmin: true,
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
return
}
// Ensure existing admins have app tiles if access was never set
for (const user of existing) {
if (!user.isAdmin) continue
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
app.log.info(`granted full catalog access to admin ${user.email}`)
}
}
}
export async function buildApp(opts: {
config: AppConfig
databaseUrl?: string
}): Promise<FastifyInstance> {
const config = opts.config
const dbUrl = opts.databaseUrl ?? config.databaseUrl
const path = dbUrl.replace(/^sqlite:/, '')
if (path !== ':memory:') {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
await app.register(sensible)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
})
app.get('/ready', async () => {
healthCheck(sqlite)
return { ok: true }
})
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
}
return app
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod'
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
adminEmail: z.string().email().default('[email protected]'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
isProd: z.boolean(),
})
export type AppConfig = z.infer<typeof configSchema>
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer: env.ISSUER ?? 'https://auth.shnt.top',
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist: env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd,
})
}
+116
View File
@@ -0,0 +1,116 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
export type AuthUser = {
id: string
email: string
name: string
isAdmin: boolean
apps: AppId[]
permissions: string[]
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss: string
}
user: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss: string
}
}
}
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser
}
}
function asAppIds(apps: string[]): AppId[] {
return apps.filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
}
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
const permissions = getUserPermissions(request.server.db, user.id)
return {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
try {
await request.jwtVerify()
} catch {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const sub = request.user.sub
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = loadAuthUser(request, row)
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
await requireAuth(request, reply)
if (reply.sent) return
if (!request.authUser?.isAdmin) {
return reply.status(403).send({
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
})
}
}
+208
View File
@@ -0,0 +1,208 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import {
createUser,
deleteUser,
getUserApps,
getUserByEmail,
getUserById,
getUserPermissions,
listUsers,
setUserAccess,
updateUser,
} from '@authportal/db'
import {
APP_IDS,
allPermissionKeys,
createUserRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
const allowedPermissions = new Set(allPermissionKeys())
function mapUser(
db: FastifyInstance['db'],
user: NonNullable<ReturnType<typeof getUserById>>,
): AdminUser {
const apps = getUserApps(db, user.id).filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
const permissions = getUserPermissions(db, user.id)
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps,
permissions,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
}
function validateAccess(apps: string[], permissions: string[]): string | null {
for (const app of apps) {
if (!(APP_IDS as readonly string[]).includes(app)) {
return `Неизвестное приложение: ${app}`
}
}
for (const p of permissions) {
if (!allowedPermissions.has(p)) {
return `Неизвестное право: ${p}`
}
}
return null
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/users', async () => {
return listUsers(app.db).map((u) => mapUser(app.db, u))
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return mapUser(app.db, user)
},
)
app.post('/api/v1/admin/users', async (request, reply) => {
const parsed = createUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const data = parsed.data
if (getUserByEmail(app.db, data.email)) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
const accessError = validateAccess(data.apps, data.permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, user.id, data.apps, data.permissions)
return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!))
})
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const parsed = patchUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
if (parsed.data.email && parsed.data.email !== existing.email) {
const clash = getUserByEmail(app.db, parsed.data.email)
if (clash) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
name: parsed.data.name,
passwordHash,
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
return mapUser(app.db, updated!)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
if (request.authUser?.id === request.params.id) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Нельзя удалить себя',
},
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return reply.status(204).send()
},
)
app.put<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/access',
async (request, reply) => {
const parsed = putUserAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const accessError = validateAccess(
parsed.data.apps,
parsed.data.permissions,
)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
setUserAccess(
app.db,
request.params.id,
parsed.data.apps,
parsed.data.permissions,
)
return mapUser(app.db, getUserById(app.db, request.params.id)!)
},
)
}
+129
View File
@@ -0,0 +1,129 @@
import type { FastifyInstance } from 'fastify'
import { hash, verify } from '@node-rs/argon2'
import { randomBytes } from 'node:crypto'
import {
createRefreshSession,
getUserApps,
getUserByEmail,
getUserPermissions,
revokeRefreshSession,
} from '@authportal/db'
import {
APPS,
PERMISSION_CATALOG,
loginRequestSchema,
type LoginResponse,
} from '@authportal/shared'
import { requireAuth, toMe } from '../plugins/auth-guards.js'
const REFRESH_COOKIE = 'refresh_token'
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const parsed = loginRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { email, password } = parsed.data
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const ok = await verify(user.passwordHash, password)
if (!ok) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const apps = getUserApps(app.db, user.id)
const permissions = getUserPermissions(app.db, user.id)
const me = toMe(user, apps, permissions)
const expiresAt = new Date(
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
)
const accessToken = app.jwt.sign(
{
sub: user.id,
email: user.email,
name: user.name,
apps,
permissions,
is_admin: user.isAdmin,
iss: app.config.issuer,
},
{ expiresIn: `${app.config.jwtTtlHours}h` },
)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
const body: LoginResponse = {
access_token: accessToken,
expires_at: expiresAt.toISOString(),
token_type: 'Bearer',
user: me,
}
return body
},
})
app.post(
'/api/v1/auth/logout',
{ onRequest: requireAuth },
async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
}
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
)
return { ok: true }
},
)
app.get(
'/api/v1/auth/me',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return {
id: auth.id,
email: auth.email,
name: auth.name,
is_admin: auth.isAdmin,
apps: auth.apps,
permissions: auth.permissions,
}
},
)
app.get(
'/api/v1/catalog',
{ onRequest: requireAuth },
async () => ({
apps: APPS,
permissions: PERMISSION_CATALOG,
}),
)
}
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
const app = await buildApp({ config })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
},
})