init studio auth backend
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { findUserById } from './repository'
|
||||
import { verifyAccessToken } from '../security/tokens'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
export type AuthVariables = {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export const authRequired: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => {
|
||||
const header = c.req.header('authorization')
|
||||
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1]
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: 'missing_authorization' }, 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = await verifyAccessToken(token)
|
||||
const user = await findUserById(claims.sub)
|
||||
if (!user) return c.json({ error: 'invalid_authorization' }, 401)
|
||||
c.set('user', user)
|
||||
return next()
|
||||
} catch {
|
||||
return c.json({ error: 'invalid_authorization' }, 401)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { config } from '../config'
|
||||
import { prisma } from '../prisma'
|
||||
import { hashPassword, verifyPassword } from '../security/password'
|
||||
import { redis } from '../redis'
|
||||
import { createOpaqueToken, hashToken } from '../security/tokens'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
type UserWithAffiliation = {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
createdAt: Date
|
||||
lastLoginAt: Date | null
|
||||
appRoles?: Array<{
|
||||
appKey: string
|
||||
roleCode: string
|
||||
}>
|
||||
affiliations?: Array<{
|
||||
id: string
|
||||
organizationId: string
|
||||
department: string | null
|
||||
position: string | null
|
||||
organization: {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
}>
|
||||
userLicenses?: Array<{
|
||||
id: string
|
||||
startsAt: Date
|
||||
expiresAt: Date | null
|
||||
license: {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const activeLicenseWhere = () => {
|
||||
const now = new Date()
|
||||
return {
|
||||
startsAt: { lte: now },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
license: { isActive: true }
|
||||
}
|
||||
}
|
||||
|
||||
const currentLicenseRelation = () => ({
|
||||
where: activeLicenseWhere(),
|
||||
orderBy: { startsAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
startsAt: true,
|
||||
expiresAt: true,
|
||||
license: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as const
|
||||
|
||||
const userSelect = () => {
|
||||
const now = new Date()
|
||||
return {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
affiliations: {
|
||||
where: { isPrimary: true, endsAt: null },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
department: true,
|
||||
position: true,
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
userLicenses: currentLicenseRelation(),
|
||||
appRoles: {
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
|
||||
{ OR: [{ endsAt: null }, { endsAt: { gt: now } }] }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
appKey: true,
|
||||
roleCode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toAuthUser = (user: UserWithAffiliation): AuthUser => {
|
||||
const primaryAffiliation = user.affiliations?.[0]
|
||||
const currentLicense = user.userLicenses?.[0]
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
appRoles: user.appRoles ?? [],
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
primaryAffiliation: primaryAffiliation
|
||||
? {
|
||||
id: primaryAffiliation.id,
|
||||
organizationId: primaryAffiliation.organizationId,
|
||||
organizationName: primaryAffiliation.organization.name,
|
||||
organizationType: primaryAffiliation.organization.type,
|
||||
department: primaryAffiliation.department,
|
||||
position: primaryAffiliation.position
|
||||
}
|
||||
: null,
|
||||
currentLicense: currentLicense
|
||||
? {
|
||||
id: currentLicense.id,
|
||||
code: currentLicense.license.code,
|
||||
name: currentLicense.license.name,
|
||||
startsAt: currentLicense.startsAt,
|
||||
expiresAt: currentLicense.expiresAt
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export async function findUserByEmail(email: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: {
|
||||
affiliations: {
|
||||
where: { isPrimary: true, endsAt: null },
|
||||
take: 1,
|
||||
include: { organization: true }
|
||||
},
|
||||
userLicenses: {
|
||||
where: activeLicenseWhere(),
|
||||
orderBy: { startsAt: 'desc' },
|
||||
take: 1,
|
||||
include: { license: true }
|
||||
},
|
||||
appRoles: {
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ startsAt: null }, { startsAt: { lte: new Date() } }] },
|
||||
{ OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }] }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
appKey: true,
|
||||
roleCode: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function createUser(input: { email: string; password: string; name?: string | null }): Promise<AuthUser> {
|
||||
const freeLicense = await prisma.license.findUnique({
|
||||
where: { code: 'FREE' },
|
||||
select: { defaultDurationDays: true }
|
||||
})
|
||||
const licenseStartsAt = new Date()
|
||||
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: input.email.toLowerCase(),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
name: input.name?.trim() || null,
|
||||
userLicenses: {
|
||||
create: {
|
||||
startsAt: licenseStartsAt,
|
||||
expiresAt: licenseExpiresAt,
|
||||
license: {
|
||||
connect: { code: 'FREE' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
select: userSelect()
|
||||
})
|
||||
|
||||
return toAuthUser(user)
|
||||
}
|
||||
|
||||
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
|
||||
if (!durationDays) return null
|
||||
|
||||
const expiresAt = new Date(startsAt)
|
||||
expiresAt.setDate(expiresAt.getDate() + durationDays)
|
||||
return expiresAt
|
||||
}
|
||||
|
||||
export async function createManagedUser(input: {
|
||||
email: string
|
||||
password: string
|
||||
name?: string | null
|
||||
organizationId: string
|
||||
department?: string | null
|
||||
position?: string | null
|
||||
licenseCode?: 'FREE' | 'EDU' | 'PRO'
|
||||
}): Promise<AuthUser> {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: input.email.toLowerCase(),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
name: input.name?.trim() || null,
|
||||
role: 'USER',
|
||||
affiliations: {
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
department: input.department?.trim() || null,
|
||||
position: input.position?.trim() || null,
|
||||
isPrimary: true
|
||||
}
|
||||
},
|
||||
userLicenses: {
|
||||
create: {
|
||||
license: {
|
||||
connect: { code: input.licenseCode ?? 'EDU' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
select: userSelect()
|
||||
})
|
||||
|
||||
return toAuthUser(user)
|
||||
}
|
||||
|
||||
export async function updateUserPassword(userId: string, password: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await hashPassword(password) }
|
||||
})
|
||||
}
|
||||
|
||||
export async function changeUserPassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: userId, isActive: true },
|
||||
select: { passwordHash: true }
|
||||
})
|
||||
|
||||
if (!user) return false
|
||||
|
||||
const passwordOk = await verifyPassword(currentPassword, user.passwordHash)
|
||||
if (!passwordOk) return false
|
||||
|
||||
await updateUserPassword(userId, newPassword)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageData: Buffer.from(imageData),
|
||||
profileImageMime: mimeType,
|
||||
profileImageUpdatedAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteUserProfileImage(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
profileImageData: null,
|
||||
profileImageMime: null,
|
||||
profileImageUpdatedAt: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: userId, isActive: true },
|
||||
select: {
|
||||
profileImageData: true,
|
||||
profileImageMime: true,
|
||||
profileImageUpdatedAt: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user?.profileImageData || !user.profileImageMime) return null
|
||||
|
||||
return {
|
||||
data: Buffer.from(user.profileImageData),
|
||||
mimeType: user.profileImageMime,
|
||||
updatedAt: user.profileImageUpdatedAt
|
||||
}
|
||||
}
|
||||
|
||||
export async function findUserById(id: string): Promise<AuthUser | null> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id, isActive: true },
|
||||
select: userSelect()
|
||||
})
|
||||
|
||||
return user ? toAuthUser(user) : null
|
||||
}
|
||||
|
||||
export async function listOrganizations() {
|
||||
return prisma.organization.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
domain: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
||||
return prisma.organization.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
domain: input.domain?.trim() || null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
domain: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function assignUserAffiliation(input: {
|
||||
userId: string
|
||||
organizationId: string
|
||||
department?: string | null
|
||||
position?: string | null
|
||||
isPrimary?: boolean
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (input.isPrimary) {
|
||||
await tx.userAffiliation.updateMany({
|
||||
where: { userId: input.userId, endsAt: null },
|
||||
data: { isPrimary: false }
|
||||
})
|
||||
}
|
||||
|
||||
return tx.userAffiliation.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId,
|
||||
department: input.department?.trim() || null,
|
||||
position: input.position?.trim() || null,
|
||||
isPrimary: input.isPrimary ?? true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
department: true,
|
||||
position: true,
|
||||
isPrimary: true,
|
||||
organization: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function markLastLogin(userId: string, loggedInAt = new Date()): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: loggedInAt }
|
||||
})
|
||||
}
|
||||
|
||||
export async function isLoginBlocked(email: string): Promise<boolean> {
|
||||
const attempts = Number(await redis.get(loginAttemptKey(email)))
|
||||
return Number.isFinite(attempts) && attempts >= config.maxFailedLoginAttempts
|
||||
}
|
||||
|
||||
export async function recordFailedLogin(email: string): Promise<void> {
|
||||
const attempts = await redis.incr(loginAttemptKey(email))
|
||||
if (attempts === 1) {
|
||||
await redis.expire(loginAttemptKey(email), config.loginAttemptWindowSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLoginAttempts(email: string): Promise<void> {
|
||||
await redis.del(loginAttemptKey(email))
|
||||
}
|
||||
|
||||
export async function storeEmailVerificationCode(email: string, code: string): Promise<void> {
|
||||
await redis.set(
|
||||
emailVerificationKey(email),
|
||||
JSON.stringify({ code, email: email.toLowerCase() }),
|
||||
'EX',
|
||||
config.emailVerificationTtlSeconds
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifyEmailVerificationCode(email: string, code: string): Promise<boolean> {
|
||||
const key = emailVerificationKey(email)
|
||||
const tokenJson = await redis.get(key)
|
||||
if (typeof tokenJson !== 'string') return false
|
||||
|
||||
const token = parseStoredEmailVerification(tokenJson)
|
||||
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
|
||||
|
||||
await redis.del(key)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function storePasswordResetCode(email: string, code: string): Promise<void> {
|
||||
await redis.set(
|
||||
passwordResetKey(email),
|
||||
JSON.stringify({ code, email: email.toLowerCase() }),
|
||||
'EX',
|
||||
config.emailVerificationTtlSeconds
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifyPasswordResetCode(email: string, code: string): Promise<boolean> {
|
||||
const key = passwordResetKey(email)
|
||||
const tokenJson = await redis.get(key)
|
||||
if (typeof tokenJson !== 'string') return false
|
||||
|
||||
const token = parseStoredEmailVerification(tokenJson)
|
||||
if (!token || token.email !== email.toLowerCase() || token.code !== code) return false
|
||||
|
||||
await redis.del(key)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = createOpaqueToken()
|
||||
await redis.set(refreshTokenKey(token), JSON.stringify({ userId }), 'EX', config.refreshTokenTtlSeconds)
|
||||
return token
|
||||
}
|
||||
|
||||
export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser | null> {
|
||||
const tokenJson = await redis.send('GETDEL', [refreshTokenKey(refreshToken)])
|
||||
if (typeof tokenJson !== 'string') return null
|
||||
|
||||
const token = parseStoredRefreshToken(tokenJson)
|
||||
if (!token) return null
|
||||
|
||||
return findUserById(token.userId)
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
|
||||
await redis.del(refreshTokenKey(refreshToken))
|
||||
}
|
||||
|
||||
export async function createHandoffToken(userId: string, audienceApp: string): Promise<string> {
|
||||
const token = createOpaqueToken()
|
||||
await redis.set(
|
||||
handoffTokenKey(token),
|
||||
JSON.stringify({ userId, audienceApp }),
|
||||
'EX',
|
||||
config.handoffTokenTtlSeconds
|
||||
)
|
||||
return token
|
||||
}
|
||||
|
||||
export async function exchangeHandoffToken(handoffToken: string, audienceApp?: string): Promise<AuthUser | null> {
|
||||
const tokenJson = await redis.send('GETDEL', [handoffTokenKey(handoffToken)])
|
||||
if (typeof tokenJson !== 'string') return null
|
||||
|
||||
const token = parseStoredHandoffToken(tokenJson)
|
||||
if (!token || (audienceApp && token.audienceApp !== audienceApp)) return null
|
||||
|
||||
return findUserById(token.userId)
|
||||
}
|
||||
|
||||
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
|
||||
|
||||
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
|
||||
|
||||
const loginAttemptKey = (email: string): string => `auth:login_attempt:${hashToken(email.toLowerCase())}`
|
||||
|
||||
const emailVerificationKey = (email: string): string => `auth:email_verify:${hashToken(email.toLowerCase())}`
|
||||
|
||||
const passwordResetKey = (email: string): string => `auth:password_reset:${hashToken(email.toLowerCase())}`
|
||||
|
||||
const parseStoredRefreshToken = (value: string): { userId: string } | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed.userId === 'string' ? { userId: parsed.userId } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parseStoredHandoffToken = (value: string): { userId: string; audienceApp: string } | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed.userId === 'string' && typeof parsed.audienceApp === 'string'
|
||||
? { userId: parsed.userId, audienceApp: parsed.audienceApp }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parseStoredEmailVerification = (value: string): { email: string; code: string } | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed.email === 'string' && typeof parsed.code === 'string'
|
||||
? { email: parsed.email, code: parsed.code }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import { Hono } from 'hono'
|
||||
import { randomInt } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config'
|
||||
import { sendEmailVerificationCode } from '../mail'
|
||||
import { verifyPassword } from '../security/password'
|
||||
import { signAccessToken } from '../security/tokens'
|
||||
import type { AuthUser } from '../types'
|
||||
import { authRequired, type AuthVariables } from './middleware'
|
||||
import {
|
||||
clearLoginAttempts,
|
||||
assignUserAffiliation,
|
||||
changeUserPassword,
|
||||
createManagedUser,
|
||||
createOrganization,
|
||||
createHandoffToken,
|
||||
createRefreshToken,
|
||||
createUser,
|
||||
deleteUserProfileImage,
|
||||
exchangeHandoffToken,
|
||||
findUserProfileImage,
|
||||
findUserByEmail,
|
||||
listOrganizations,
|
||||
isLoginBlocked,
|
||||
markLastLogin,
|
||||
recordFailedLogin,
|
||||
revokeRefreshToken,
|
||||
rotateRefreshToken,
|
||||
storeEmailVerificationCode,
|
||||
storePasswordResetCode,
|
||||
updateUserPassword,
|
||||
updateUserProfileImage,
|
||||
verifyEmailVerificationCode,
|
||||
verifyPasswordResetCode
|
||||
} from './repository'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
verificationCode: z.string().regex(/^\d{6}$/)
|
||||
})
|
||||
|
||||
const emailVerificationSendSchema = z.object({
|
||||
email: z.string().email()
|
||||
})
|
||||
|
||||
const passwordResetSendSchema = z.object({
|
||||
email: z.string().email()
|
||||
})
|
||||
|
||||
const passwordResetConfirmSchema = z.object({
|
||||
email: z.string().email(),
|
||||
verificationCode: z.string().regex(/^\d{6}$/),
|
||||
password: z.string().min(8)
|
||||
})
|
||||
|
||||
const passwordChangeSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8)
|
||||
}).refine((value) => value.currentPassword !== value.newPassword, {
|
||||
path: ['newPassword'],
|
||||
message: 'new_password_must_be_different'
|
||||
})
|
||||
|
||||
const organizationTypeSchema = z.enum(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER'])
|
||||
|
||||
const createOrganizationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
type: organizationTypeSchema.default('OTHER'),
|
||||
domain: z.string().trim().max(120).optional().nullable()
|
||||
})
|
||||
|
||||
const assignAffiliationSchema = z.object({
|
||||
organizationId: z.string().uuid(),
|
||||
department: z.string().trim().max(120).optional().nullable(),
|
||||
position: z.string().trim().max(120).optional().nullable(),
|
||||
isPrimary: z.boolean().optional()
|
||||
})
|
||||
|
||||
const adminCreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
organizationId: z.string().uuid(),
|
||||
department: z.string().trim().max(120).optional().nullable(),
|
||||
position: z.string().trim().max(120).optional().nullable(),
|
||||
licenseCode: z.enum(['FREE', 'EDU', 'PRO']).optional()
|
||||
})
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
const logoutSchema = z.object({
|
||||
refreshToken: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
const handoffSchema = z.object({
|
||||
audienceApp: z.string().min(1).max(120)
|
||||
})
|
||||
|
||||
const handoffExchangeSchema = z.object({
|
||||
handoffToken: z.string().min(1),
|
||||
audienceApp: z.string().min(1).max(120).optional()
|
||||
})
|
||||
|
||||
const AVATAR_MAX_BYTES = 80 * 1024
|
||||
const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg'])
|
||||
|
||||
const userResponse = (user: AuthUser) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
roles: [...new Set([user.role, ...(user.appRoles ?? []).map((appRole) => appRole.roleCode)])],
|
||||
appRoles: user.appRoles ?? [],
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt,
|
||||
primaryAffiliation: user.primaryAffiliation ?? null,
|
||||
currentLicense: user.currentLicense
|
||||
? {
|
||||
...user.currentLicense,
|
||||
startsAt: user.currentLicense.startsAt instanceof Date
|
||||
? user.currentLicense.startsAt.toISOString()
|
||||
: user.currentLicense.startsAt,
|
||||
expiresAt: user.currentLicense.expiresAt instanceof Date
|
||||
? user.currentLicense.expiresAt.toISOString()
|
||||
: user.currentLicense.expiresAt
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN'
|
||||
|
||||
const accessTokenResponse = async (user: AuthUser) => ({
|
||||
accessToken: await signAccessToken(user),
|
||||
accessTokenExpiresIn: config.accessTokenTtlSeconds,
|
||||
user: userResponse(user)
|
||||
})
|
||||
|
||||
const tokenPairResponse = async (user: AuthUser) => ({
|
||||
...(await accessTokenResponse(user)),
|
||||
refreshToken: await createRefreshToken(user.id),
|
||||
refreshTokenExpiresIn: config.refreshTokenTtlSeconds
|
||||
})
|
||||
|
||||
const cookieAttributes = (maxAgeSeconds: number): string[] => [
|
||||
'HttpOnly',
|
||||
'Path=/',
|
||||
'SameSite=Lax',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
...(config.refreshCookieSecure ? ['Secure'] : [])
|
||||
]
|
||||
|
||||
const setRefreshCookie = (c: { header: (name: string, value: string) => void }, refreshToken: string): void => {
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
`${config.refreshCookieName}=${encodeURIComponent(refreshToken)}; ${cookieAttributes(config.refreshTokenTtlSeconds).join('; ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const clearRefreshCookie = (c: { header: (name: string, value: string) => void }): void => {
|
||||
c.header('Set-Cookie', `${config.refreshCookieName}=; ${cookieAttributes(0).join('; ')}`)
|
||||
}
|
||||
|
||||
const readCookie = (cookieHeader: string | undefined, name: string): string | undefined => {
|
||||
if (!cookieHeader) return undefined
|
||||
|
||||
const prefix = `${name}=`
|
||||
const cookie = cookieHeader
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix))
|
||||
|
||||
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : undefined
|
||||
}
|
||||
|
||||
const readRefreshToken = async (c: { req: { header: (name: string) => string | undefined; json: () => Promise<unknown> } }) => {
|
||||
const cookieToken = readCookie(c.req.header('cookie'), config.refreshCookieName)
|
||||
if (cookieToken) return cookieToken
|
||||
|
||||
const body = refreshSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
return body.success ? body.data.refreshToken : undefined
|
||||
}
|
||||
|
||||
export const authRoutes = new Hono<{ Variables: AuthVariables }>()
|
||||
|
||||
authRoutes.post('/email-verification/send', async (c) => {
|
||||
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
||||
|
||||
const code = String(randomInt(100000, 1000000))
|
||||
await storeEmailVerificationCode(email, code)
|
||||
|
||||
try {
|
||||
await sendEmailVerificationCode(email, code)
|
||||
} catch (error) {
|
||||
console.error('Failed to send email verification code', error)
|
||||
return c.json({ error: 'email_send_failed' }, 502)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
expiresIn: config.emailVerificationTtlSeconds
|
||||
})
|
||||
})
|
||||
|
||||
authRoutes.post('/password-reset/send', async (c) => {
|
||||
const body = passwordResetSendSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
|
||||
if (!existingUser?.isActive) {
|
||||
return c.json({ error: 'email_not_registered' }, 404)
|
||||
}
|
||||
|
||||
const code = String(randomInt(100000, 1000000))
|
||||
await storePasswordResetCode(email, code)
|
||||
|
||||
try {
|
||||
await sendEmailVerificationCode(email, code)
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset code', error)
|
||||
return c.json({ error: 'email_send_failed' }, 502)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
expiresIn: config.emailVerificationTtlSeconds
|
||||
})
|
||||
})
|
||||
|
||||
authRoutes.post('/password-reset/confirm', async (c) => {
|
||||
const body = passwordResetConfirmSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
if (!existingUser?.isActive) return c.json({ error: 'invalid_password_reset_code' }, 400)
|
||||
|
||||
const codeVerified = await verifyPasswordResetCode(email, body.data.verificationCode)
|
||||
if (!codeVerified) return c.json({ error: 'invalid_password_reset_code' }, 400)
|
||||
|
||||
await updateUserPassword(existingUser.id, body.data.password)
|
||||
await clearLoginAttempts(email)
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
authRoutes.post('/password/change', authRequired, async (c) => {
|
||||
const body = passwordChangeSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const changed = await changeUserPassword(
|
||||
c.get('user').id,
|
||||
body.data.currentPassword,
|
||||
body.data.newPassword
|
||||
)
|
||||
|
||||
if (!changed) return c.json({ error: 'invalid_current_password' }, 400)
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
authRoutes.post('/login', async (c) => {
|
||||
const body = loginSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
if (await isLoginBlocked(email)) return c.json({ error: 'too_many_login_attempts' }, 429)
|
||||
|
||||
const dbUser = await findUserByEmail(email)
|
||||
if (!dbUser || !dbUser.isActive) {
|
||||
await recordFailedLogin(email)
|
||||
return c.json({ error: 'invalid_credentials' }, 401)
|
||||
}
|
||||
|
||||
const passwordOk = await verifyPassword(body.data.password, dbUser.passwordHash)
|
||||
if (!passwordOk) {
|
||||
await recordFailedLogin(email)
|
||||
return c.json({ error: 'invalid_credentials' }, 401)
|
||||
}
|
||||
|
||||
const loggedInAt = new Date()
|
||||
const user: AuthUser = {
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
name: dbUser.name,
|
||||
role: dbUser.role,
|
||||
appRoles: dbUser.appRoles ?? [],
|
||||
createdAt: dbUser.createdAt,
|
||||
lastLoginAt: loggedInAt,
|
||||
primaryAffiliation: dbUser.affiliations?.[0]
|
||||
? {
|
||||
id: dbUser.affiliations[0].id,
|
||||
organizationId: dbUser.affiliations[0].organizationId,
|
||||
organizationName: dbUser.affiliations[0].organization.name,
|
||||
organizationType: dbUser.affiliations[0].organization.type,
|
||||
department: dbUser.affiliations[0].department,
|
||||
position: dbUser.affiliations[0].position
|
||||
}
|
||||
: null,
|
||||
currentLicense: dbUser.userLicenses?.[0]
|
||||
? {
|
||||
id: dbUser.userLicenses[0].id,
|
||||
code: dbUser.userLicenses[0].license.code,
|
||||
name: dbUser.userLicenses[0].license.name,
|
||||
startsAt: dbUser.userLicenses[0].startsAt,
|
||||
expiresAt: dbUser.userLicenses[0].expiresAt
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
await markLastLogin(user.id, loggedInAt)
|
||||
await clearLoginAttempts(email)
|
||||
|
||||
const refreshToken = await createRefreshToken(user.id)
|
||||
setRefreshCookie(c, refreshToken)
|
||||
|
||||
return c.json(await accessTokenResponse(user))
|
||||
})
|
||||
|
||||
authRoutes.post('/signup', async (c) => {
|
||||
const body = signupSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
||||
|
||||
const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode)
|
||||
if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400)
|
||||
|
||||
const user = await createUser({
|
||||
email,
|
||||
password: body.data.password,
|
||||
name: body.data.name
|
||||
})
|
||||
|
||||
const refreshToken = await createRefreshToken(user.id)
|
||||
setRefreshCookie(c, refreshToken)
|
||||
|
||||
return c.json(await accessTokenResponse(user), 201)
|
||||
})
|
||||
|
||||
authRoutes.post('/refresh', async (c) => {
|
||||
const refreshToken = await readRefreshToken(c)
|
||||
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
||||
|
||||
const user = await rotateRefreshToken(refreshToken)
|
||||
if (!user) {
|
||||
clearRefreshCookie(c)
|
||||
return c.json({ error: 'invalid_refresh_token' }, 401)
|
||||
}
|
||||
|
||||
const nextRefreshToken = await createRefreshToken(user.id)
|
||||
setRefreshCookie(c, nextRefreshToken)
|
||||
|
||||
return c.json(await accessTokenResponse(user))
|
||||
})
|
||||
|
||||
authRoutes.post('/logout', async (c) => {
|
||||
const body = logoutSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
const refreshToken = body.success && body.data.refreshToken
|
||||
? body.data.refreshToken
|
||||
: readCookie(c.req.header('cookie'), config.refreshCookieName)
|
||||
|
||||
if (refreshToken) {
|
||||
await revokeRefreshToken(refreshToken)
|
||||
}
|
||||
|
||||
clearRefreshCookie(c)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
authRoutes.get('/me', authRequired, (c) => {
|
||||
return c.json({ user: userResponse(c.get('user')) })
|
||||
})
|
||||
|
||||
authRoutes.get('/profile/avatar', authRequired, async (c) => {
|
||||
const image = await findUserProfileImage(c.get('user').id)
|
||||
if (!image) return c.body(null, 404)
|
||||
|
||||
c.header('content-type', image.mimeType)
|
||||
c.header('cache-control', 'no-store')
|
||||
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
||||
|
||||
return c.body(new Uint8Array(image.data))
|
||||
})
|
||||
|
||||
authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const userId = c.req.param('userId')
|
||||
const image = await findUserProfileImage(userId)
|
||||
if (!image) return c.body(null, 404)
|
||||
|
||||
c.header('content-type', image.mimeType)
|
||||
c.header('cache-control', 'no-store')
|
||||
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
||||
|
||||
return c.body(new Uint8Array(image.data))
|
||||
})
|
||||
|
||||
authRoutes.get('/users/avatar', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const email = c.req.query('email')?.toLowerCase()
|
||||
if (!email) return c.json({ error: 'email_required' }, 400)
|
||||
|
||||
const user = await findUserByEmail(email)
|
||||
if (!user) return c.body(null, 404)
|
||||
|
||||
const image = await findUserProfileImage(user.id)
|
||||
if (!image) return c.body(null, 404)
|
||||
|
||||
c.header('content-type', image.mimeType)
|
||||
c.header('cache-control', 'no-store')
|
||||
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
||||
|
||||
return c.body(new Uint8Array(image.data))
|
||||
})
|
||||
|
||||
authRoutes.put('/profile/avatar', authRequired, async (c) => {
|
||||
const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
if (!AVATAR_ALLOWED_MIME_TYPES.has(contentType)) {
|
||||
return c.json({ error: 'unsupported_image_type' }, 415)
|
||||
}
|
||||
|
||||
const body = new Uint8Array(await c.req.arrayBuffer())
|
||||
if (body.length === 0) return c.json({ error: 'empty_image' }, 400)
|
||||
if (body.length > AVATAR_MAX_BYTES) return c.json({ error: 'image_too_large', maxBytes: AVATAR_MAX_BYTES }, 413)
|
||||
|
||||
await updateUserProfileImage(c.get('user').id, body, contentType)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
authRoutes.delete('/profile/avatar', authRequired, async (c) => {
|
||||
await deleteUserProfileImage(c.get('user').id)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
authRoutes.get('/admin/organizations', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const organizations = await listOrganizations()
|
||||
return c.json({
|
||||
organizations: organizations.map((organization) => ({
|
||||
...organization,
|
||||
createdAt: organization.createdAt.toISOString()
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
authRoutes.post('/admin/organizations', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const body = createOrganizationSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
try {
|
||||
const organization = await createOrganization(body.data)
|
||||
return c.json({
|
||||
organization: {
|
||||
...organization,
|
||||
createdAt: organization.createdAt.toISOString()
|
||||
}
|
||||
}, 201)
|
||||
} catch {
|
||||
return c.json({ error: 'organization_already_exists' }, 409)
|
||||
}
|
||||
})
|
||||
|
||||
authRoutes.post('/admin/users', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const email = body.data.email.toLowerCase()
|
||||
const existingUser = await findUserByEmail(email)
|
||||
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
||||
|
||||
try {
|
||||
const user = await createManagedUser({
|
||||
email,
|
||||
password: body.data.password,
|
||||
name: body.data.name,
|
||||
organizationId: body.data.organizationId,
|
||||
department: body.data.department,
|
||||
position: body.data.position,
|
||||
licenseCode: body.data.licenseCode
|
||||
})
|
||||
|
||||
return c.json({ user: userResponse(user) }, 201)
|
||||
} catch {
|
||||
return c.json({ error: 'user_create_failed' }, 400)
|
||||
}
|
||||
})
|
||||
|
||||
authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => {
|
||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||
|
||||
const userId = c.req.param('userId')
|
||||
const body = assignAffiliationSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
try {
|
||||
const affiliation = await assignUserAffiliation({
|
||||
userId,
|
||||
organizationId: body.data.organizationId,
|
||||
department: body.data.department,
|
||||
position: body.data.position,
|
||||
isPrimary: body.data.isPrimary
|
||||
})
|
||||
|
||||
return c.json({ affiliation }, 201)
|
||||
} catch {
|
||||
return c.json({ error: 'affiliation_assign_failed' }, 400)
|
||||
}
|
||||
})
|
||||
|
||||
authRoutes.post('/handoff', authRequired, async (c) => {
|
||||
const body = handoffSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const token = await createHandoffToken(c.get('user').id, body.data.audienceApp)
|
||||
return c.json({
|
||||
handoffToken: token,
|
||||
expiresIn: config.handoffTokenTtlSeconds,
|
||||
audienceApp: body.data.audienceApp
|
||||
})
|
||||
})
|
||||
|
||||
authRoutes.post('/handoff/exchange', async (c) => {
|
||||
const body = handoffExchangeSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
const user = await exchangeHandoffToken(body.data.handoffToken, body.data.audienceApp)
|
||||
if (!user) return c.json({ error: 'invalid_handoff_token' }, 401)
|
||||
|
||||
return c.json(await tokenPairResponse(user))
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
const toInt = (value: string | undefined, fallback: number): number => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
const required = (name: string, fallback?: string): string => {
|
||||
const value = process.env[name] ?? fallback
|
||||
if (!value) throw new Error(`Missing required env: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: toInt(process.env.PORT, 4100),
|
||||
databaseUrl: required('DATABASE_URL', 'postgres://atnmy_auth:future1234!%40@localhost:5434/atnmy_auth'),
|
||||
redisUrl: required('REDIS_URL', 'redis://localhost:6380'),
|
||||
jwtSecret: required('JWT_SECRET', 'dev-only-change-this-secret-at-least-32-bytes'),
|
||||
jwtIssuer: process.env.JWT_ISSUER ?? 'atnmy-stdo-auth',
|
||||
accessTokenTtlSeconds: toInt(process.env.ACCESS_TOKEN_TTL_SECONDS, 60 * 60),
|
||||
refreshTokenTtlSeconds: toInt(process.env.REFRESH_TOKEN_TTL_SECONDS, 30 * 24 * 60 * 60),
|
||||
handoffTokenTtlSeconds: toInt(process.env.HANDOFF_TOKEN_TTL_SECONDS, 60),
|
||||
maxFailedLoginAttempts: toInt(process.env.MAX_FAILED_LOGIN_ATTEMPTS, 5),
|
||||
loginAttemptWindowSeconds: toInt(process.env.LOGIN_ATTEMPT_WINDOW_SECONDS, 15 * 60),
|
||||
emailVerificationTtlSeconds: toInt(process.env.EMAIL_VERIFICATION_TTL_SECONDS, 5 * 60),
|
||||
refreshCookieName: process.env.REFRESH_COOKIE_NAME ?? 'atnmy_refresh',
|
||||
refreshCookieSecure: process.env.REFRESH_COOKIE_SECURE === 'true',
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean),
|
||||
seedAdminEmail: process.env.SEED_ADMIN_EMAIL ?? 'admin@futuredrive.net',
|
||||
seedAdminPassword: process.env.SEED_ADMIN_PASSWORD ?? 'admin1234',
|
||||
smtpHost: process.env.SMTP_HOST,
|
||||
smtpPort: toInt(process.env.SMTP_PORT, 587),
|
||||
smtpSecure: process.env.SMTP_SECURE === 'true',
|
||||
smtpUser: process.env.SMTP_USER,
|
||||
smtpPass: process.env.SMTP_PASS,
|
||||
smtpFrom: process.env.SMTP_FROM
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { config } from './config'
|
||||
import { prisma } from './prisma'
|
||||
import { closeRedis, redis } from './redis'
|
||||
import { authRoutes } from './auth/routes'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
if (!origin) return null
|
||||
return config.corsOrigins.includes(origin) ? origin : null
|
||||
},
|
||||
allowHeaders: ['authorization', 'content-type'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'],
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/health', async (c) => {
|
||||
const redisOk = (await redis.send('PING', [])) === 'PONG'
|
||||
return c.json({ ok: true, service: 'atnmy-stdo-auth', redis: redisOk ? 'ok' : 'error' }, redisOk ? 200 : 503)
|
||||
})
|
||||
app.route('/auth', authRoutes)
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await closeRedis()
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
console.log(`Auth server listening on http://localhost:${info.port}`)
|
||||
})
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { config } from './config'
|
||||
|
||||
const createTransporter = () => {
|
||||
if (!config.smtpHost || !config.smtpUser || !config.smtpPass || !config.smtpFrom) {
|
||||
throw new Error('SMTP configuration is incomplete')
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: config.smtpHost,
|
||||
port: config.smtpPort,
|
||||
secure: config.smtpSecure,
|
||||
auth: {
|
||||
user: config.smtpUser,
|
||||
pass: config.smtpPass
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let transporter: ReturnType<typeof createTransporter> | null = null
|
||||
|
||||
export async function sendEmailVerificationCode(to: string, code: string): Promise<void> {
|
||||
transporter ??= createTransporter()
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.smtpFrom,
|
||||
to,
|
||||
subject: '[Autonomy Studio] 이메일 인증코드',
|
||||
text: `Autonomy Studio 이메일 인증코드는 ${code} 입니다. ${Math.floor(config.emailVerificationTtlSeconds / 60)}분 안에 입력해주세요.`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#111827">
|
||||
<h2 style="margin:0 0 12px">Autonomy Studio 이메일 인증</h2>
|
||||
<p style="margin:0 0 16px">아래 인증코드를 회원가입 화면에 입력해주세요.</p>
|
||||
<div style="font-size:28px;font-weight:700;letter-spacing:6px;padding:16px 18px;background:#f3f4f6;border-radius:8px;display:inline-block">${code}</div>
|
||||
<p style="margin:16px 0 0;color:#6b7280;font-size:13px">${Math.floor(config.emailVerificationTtlSeconds / 60)}분 후 만료됩니다.</p>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
export const prisma = new PrismaClient()
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RedisClient } from 'bun'
|
||||
import { config } from './config'
|
||||
|
||||
export const redis = new RedisClient(config.redisUrl, {
|
||||
autoReconnect: true,
|
||||
enableOfflineQueue: true
|
||||
})
|
||||
|
||||
export async function closeRedis(): Promise<void> {
|
||||
redis.close()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const scrypt = promisify(scryptCallback)
|
||||
const KEY_LENGTH = 64
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const derivedKey = (await scrypt(password, salt, KEY_LENGTH)) as Buffer
|
||||
|
||||
return `scrypt:${salt}:${derivedKey.toString('hex')}`
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
const [algorithm, salt, key] = passwordHash.split(':')
|
||||
|
||||
if (algorithm !== 'scrypt' || !salt || !key) {
|
||||
return false
|
||||
}
|
||||
|
||||
const storedKey = Buffer.from(key, 'hex')
|
||||
const derivedKey = (await scrypt(password, salt, storedKey.length)) as Buffer
|
||||
|
||||
return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { config } from '../config'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
const secret = new TextEncoder().encode(config.jwtSecret)
|
||||
|
||||
export interface AccessTokenClaims {
|
||||
sub: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
|
||||
|
||||
export const createOpaqueToken = (): string => randomBytes(32).toString('base64url')
|
||||
|
||||
export async function signAccessToken(user: AuthUser): Promise<string> {
|
||||
return new SignJWT({
|
||||
email: user.email,
|
||||
role: user.role
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||
.setIssuer(config.jwtIssuer)
|
||||
.setSubject(user.id)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${config.accessTokenTtlSeconds}s`)
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
|
||||
const { payload } = await jwtVerify(token, secret, { issuer: config.jwtIssuer })
|
||||
if (!payload.sub || typeof payload.email !== 'string' || typeof payload.role !== 'string') {
|
||||
throw new Error('Invalid access token claims')
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
appRoles?: Array<{
|
||||
appKey: string
|
||||
roleCode: string
|
||||
}>
|
||||
createdAt?: Date | string
|
||||
lastLoginAt?: Date | string | null
|
||||
primaryAffiliation?: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
organizationType: string
|
||||
department: string | null
|
||||
position: string | null
|
||||
} | null
|
||||
currentLicense?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
startsAt: Date | string
|
||||
expiresAt: Date | string | null
|
||||
} | null
|
||||
}
|
||||
Reference in New Issue
Block a user