quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
Альтернатива паролю на портале; SSO приложений без изменений. Co-authored-by: Cursor <[email protected]>
265 lines
7.8 KiB
TypeScript
265 lines
7.8 KiB
TypeScript
import type { FastifyInstance } from 'fastify'
|
|
import { verify } from '@node-rs/argon2'
|
|
import {
|
|
PERMISSION_CATALOG,
|
|
appsMetaFromSwitcher,
|
|
loginRequestSchema,
|
|
publicAppSwitcherConfig,
|
|
ssoAccessRequestSchema,
|
|
} from '@authportal/shared'
|
|
import {
|
|
getAppSwitcherConfig,
|
|
getUserByEmail,
|
|
getUserById,
|
|
listUsers,
|
|
revokeRefreshSession,
|
|
} from '@authportal/db'
|
|
import { requireAuth } from '../plugins/auth-guards.js'
|
|
import { issueAccessToken } from '../lib/issue-access-token.js'
|
|
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
|
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
|
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
|
|
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
|
app.get('/api/v1/auth/config', async () => ({
|
|
return_to_allowlist: app.config.returnToAllowlist,
|
|
issuer: app.config.issuer,
|
|
webauthn: true,
|
|
}))
|
|
|
|
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, return_to: returnTo } = parsed.data
|
|
const ip = clientIp(request)
|
|
const userAgent = clientUserAgent(request.headers)
|
|
const targetApp = targetAppFromReturnTo(returnTo)
|
|
const user = getUserByEmail(app.db, email)
|
|
if (!user || user.disabled) {
|
|
safeAudit(app, {
|
|
action: 'auth.login_failed',
|
|
severity: 'warning',
|
|
actorEmail: email.toLowerCase(),
|
|
targetType: 'session',
|
|
summary: `Неудачный вход: ${email}`,
|
|
details: {
|
|
reason: !user ? 'unknown_user' : 'disabled',
|
|
user_agent: userAgent,
|
|
return_to: returnTo ?? null,
|
|
target_app: targetApp,
|
|
},
|
|
ip,
|
|
})
|
|
return reply.status(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
|
})
|
|
}
|
|
|
|
const ok = await verify(user.passwordHash, password)
|
|
if (!ok) {
|
|
safeAudit(app, {
|
|
action: 'auth.login_failed',
|
|
severity: 'warning',
|
|
actorUserId: user.id,
|
|
actorEmail: user.email,
|
|
actorName: user.name,
|
|
targetType: 'session',
|
|
targetId: user.id,
|
|
summary: `Неудачный вход: ${user.email}`,
|
|
details: {
|
|
reason: 'bad_password',
|
|
user_agent: userAgent,
|
|
return_to: returnTo ?? null,
|
|
target_app: targetApp,
|
|
},
|
|
ip,
|
|
})
|
|
return reply.status(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
|
})
|
|
}
|
|
|
|
return completeLogin(app, reply, user, {
|
|
method: 'password',
|
|
returnTo,
|
|
ip,
|
|
userAgent,
|
|
})
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Re-mint access JWT from DB (apps/permissions/is_admin/tenants).
|
|
* Fixes stale localStorage JWT after role changes or account switch.
|
|
*/
|
|
app.post(
|
|
'/api/v1/auth/reissue',
|
|
{
|
|
onRequest: requireAuth,
|
|
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
|
},
|
|
async (request, reply) => {
|
|
const auth = request.authUser!
|
|
const user = getUserById(app.db, auth.id)
|
|
if (!user || user.disabled) {
|
|
return reply.status(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
|
})
|
|
}
|
|
return issueAccessToken(app, user)
|
|
},
|
|
)
|
|
|
|
/** Record SSO handoff to a connected app (already authenticated). */
|
|
app.post(
|
|
'/api/v1/auth/sso-access',
|
|
{
|
|
onRequest: requireAuth,
|
|
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
|
},
|
|
async (request, reply) => {
|
|
const parsed = ssoAccessRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const auth = request.authUser!
|
|
const user = getUserById(app.db, auth.id)
|
|
if (!user || user.disabled) {
|
|
return reply.status(401).send({
|
|
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
|
})
|
|
}
|
|
const returnTo = parsed.data.return_to
|
|
const targetApp = targetAppFromReturnTo(returnTo)
|
|
const ip = clientIp(request)
|
|
const userAgent = clientUserAgent(request.headers)
|
|
safeAudit(app, {
|
|
action: 'auth.sso_handoff',
|
|
severity: 'info',
|
|
actorUserId: user.id,
|
|
actorEmail: user.email,
|
|
actorName: user.name,
|
|
targetType: 'session',
|
|
targetId: user.id,
|
|
summary: `SSO: ${user.email} → ${targetApp}`,
|
|
details: {
|
|
return_to: returnTo,
|
|
target_app: targetApp,
|
|
user_agent: userAgent,
|
|
},
|
|
ip,
|
|
})
|
|
return { ok: true, target_app: targetApp }
|
|
},
|
|
)
|
|
|
|
app.post('/api/v1/auth/logout', 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`,
|
|
)
|
|
|
|
// Best-effort actor from JWT if present
|
|
let actorUserId: string | null = null
|
|
let actorEmail: string | null = null
|
|
let actorName: string | null = null
|
|
try {
|
|
await request.jwtVerify()
|
|
const sub = request.user.sub
|
|
const row = getUserById(app.db, sub)
|
|
if (row) {
|
|
actorUserId = row.id
|
|
actorEmail = row.email
|
|
actorName = row.name
|
|
}
|
|
} catch {
|
|
/* anonymous logout */
|
|
}
|
|
|
|
safeAudit(app, {
|
|
action: 'auth.logout',
|
|
severity: 'info',
|
|
actorUserId,
|
|
actorEmail,
|
|
actorName,
|
|
targetType: 'session',
|
|
targetId: actorUserId,
|
|
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
|
|
details: { user_agent: clientUserAgent(request.headers) },
|
|
ip: clientIp(request),
|
|
})
|
|
|
|
return { ok: true }
|
|
})
|
|
|
|
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
|
app.get('/api/v1/app-switcher', async () =>
|
|
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
|
|
)
|
|
|
|
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/directory/users',
|
|
{ onRequest: requireAuth },
|
|
async (request) => {
|
|
const q = String(
|
|
(request.query as { q?: string }).q ?? '',
|
|
)
|
|
.trim()
|
|
.toLowerCase()
|
|
const all = listUsers(app.db).filter((u) => !u.disabled)
|
|
const filtered = q
|
|
? all.filter(
|
|
(u) =>
|
|
u.email.toLowerCase().includes(q) ||
|
|
u.name.toLowerCase().includes(q) ||
|
|
u.id.toLowerCase().includes(q),
|
|
)
|
|
: all
|
|
return filtered.slice(0, 50).map((u) => ({
|
|
id: u.id,
|
|
email: u.email,
|
|
name: u.name,
|
|
}))
|
|
},
|
|
)
|
|
|
|
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
|
|
const switcher = getAppSwitcherConfig(app.db)
|
|
return {
|
|
apps: appsMetaFromSwitcher(switcher),
|
|
permissions: PERMISSION_CATALOG,
|
|
}
|
|
})
|
|
}
|