Files
timelimit-server/src/api/parent.ts
T
2022-09-22 08:47:06 +02:00

348 lines
9.9 KiB
TypeScript

/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { json } from 'body-parser'
import { createHmac } from 'crypto'
import { Router } from 'express'
import { BadRequest, Forbidden, Unauthorized } from 'http-errors'
import { config } from '../config'
import { Database, Transaction } from '../database'
import { removeDevice } from '../function/device/remove-device'
import { createAddDeviceToken } from '../function/parent/create-add-device-token'
import { createFamily } from '../function/parent/create-family'
import { getStatusByMailToken } from '../function/parent/get-status-by-mail-address'
import { linkMailAddress } from '../function/parent/link-mail-address'
import { recoverParentPassword } from '../function/parent/recover-parent-password'
import { signInIntoFamily } from '../function/parent/sign-in-into-family'
import { validateU2fIntegrity, U2fValidationError } from '../function/u2f'
import { createIdentityToken, MissingSignSecretException } from '../util/identity-token'
import { WebsocketApi } from '../websocket'
import {
isCreateFamilyByMailTokenRequest,
isCreateRegisterDeviceTokenRequest, isLinkParentMailAddressRequest,
isMailAuthTokenRequestBody, isRecoverParentPasswordRequest,
isRemoveDeviceRequest, isSignIntoFamilyRequest, isRequestIdentityTokenRequest
} from './validator'
export const createParentRouter = ({ database, websocket }: {database: Database, websocket: WebsocketApi}) => {
const router = Router()
router.post('/get-status-by-mail-address', json(), async (req, res, next) => {
try {
if (!isMailAuthTokenRequestBody(req.body)) {
throw new BadRequest()
}
const { mailAuthToken } = req.body
const { status, mail } = await database.transaction(async (transaction) => {
return getStatusByMailToken({ database, mailAuthToken, transaction })
})
res.json({
status,
mail,
canCreateFamily: !config.disableSignup,
alwaysPro: config.alwaysPro
})
} catch (ex) {
next(ex)
}
})
router.post('/create-family', json(), async (req, res, next) => {
try {
if (config.disableSignup) {
throw new Forbidden()
}
if (!isCreateFamilyByMailTokenRequest(req.body)) {
throw new BadRequest()
}
const result = await createFamily({
database,
firstParentDevice: req.body.parentDevice,
mailAuthToken: req.body.mailAuthToken,
password: req.body.parentPassword,
deviceName: req.body.deviceName,
parentName: req.body.parentName,
timeZone: req.body.timeZone
})
res.json({
deviceAuthToken: result.deviceAuthToken,
ownDeviceId: result.deviceId
})
} catch (ex) {
next(ex)
}
})
router.post('/sign-in-into-family', json(), async (req, res, next) => {
try {
if (!isSignIntoFamilyRequest(req.body)) {
throw new BadRequest()
}
const result = await signInIntoFamily({
database,
newDeviceInfo: req.body.parentDevice,
mailAuthToken: req.body.mailAuthToken,
deviceName: req.body.deviceName,
websocket
})
res.json({
deviceAuthToken: result.deviceAuthToken,
ownDeviceId: result.deviceId
})
} catch (ex) {
next(ex)
}
})
router.post('/recover-parent-password', json(), async (req, res, next) => {
try {
if (!isRecoverParentPasswordRequest(req.body)) {
throw new BadRequest()
}
await recoverParentPassword({
database,
websocket,
password: req.body.password,
mailAuthToken: req.body.mailAuthToken
})
res.json({ ok: true })
} catch (ex) {
next(ex)
}
})
async function assertAuthValidAndReturnDetails ({ deviceAuthToken, parentId, secondPasswordHash, transaction }: {
deviceAuthToken: string
parentId: string
secondPasswordHash: string
transaction: Transaction
}) {
const deviceEntry = await database.device.findOne({
where: {
deviceAuthToken: deviceAuthToken
},
transaction
})
if (!deviceEntry) {
throw new Unauthorized()
}
if (secondPasswordHash === 'device') {
if (!deviceEntry.isUserKeptSignedIn) {
throw new Unauthorized()
}
const parentEntry = await database.user.findOne({
where: {
familyId: deviceEntry.familyId,
type: 'parent',
userId: deviceEntry.currentUserId
},
transaction
})
if (!parentEntry) {
throw new Unauthorized()
}
return { deviceEntry, parentEntry }
} else if (secondPasswordHash.startsWith('u2f:')) {
try {
const familyEntryUnsafe = await database.family.findOne({
where: {
familyId: deviceEntry.familyId
},
transaction,
attributes: ['hasFullVersion']
})
if (!familyEntryUnsafe) {
throw new Unauthorized()
}
const familyEntry = { hasFullVersion: familyEntryUnsafe.hasFullVersion }
const hasFullVersion = familyEntry.hasFullVersion || config.alwaysPro
const u2fResult = await validateU2fIntegrity({
integrity: secondPasswordHash,
hasFullVersion,
familyId: deviceEntry.familyId,
deviceId: deviceEntry.deviceId,
database,
transaction,
calculateHmac: (secret) => createHmac('sha256', secret)
.update('direct action')
.digest()
})
if (u2fResult.userId !== parentId) throw new Unauthorized()
const parentEntry = await database.user.findOne({
where: {
familyId: deviceEntry.familyId,
type: 'parent',
userId: u2fResult.userId
},
transaction
})
if (!parentEntry) {
throw new Unauthorized()
}
return { deviceEntry, parentEntry }
} catch (ex) {
if (ex instanceof U2fValidationError) throw new Unauthorized()
else throw ex
}
} else {
const parentEntry = await database.user.findOne({
where: {
familyId: deviceEntry.familyId,
type: 'parent',
userId: parentId,
secondPasswordHash: secondPasswordHash
},
transaction
})
if (!parentEntry) {
throw new Unauthorized()
}
return { deviceEntry, parentEntry }
}
}
router.post('/create-add-device-token', json(), async (req, res, next) => {
try {
if (!isCreateRegisterDeviceTokenRequest(req.body)) {
throw new BadRequest()
}
const { token, deviceId } = await database.transaction(async (transaction) => {
const { deviceEntry } = await assertAuthValidAndReturnDetails({
deviceAuthToken: req.body.deviceAuthToken,
parentId: req.body.parentId,
secondPasswordHash: req.body.parentPasswordSecondHash,
transaction
})
return createAddDeviceToken({ familyId: deviceEntry.familyId, database, transaction })
})
res.json({ token, deviceId })
} catch (ex) {
next(ex)
}
})
router.post('/link-mail-address', json(), async (req, res, next) => {
try {
if (!isLinkParentMailAddressRequest(req.body)) {
throw new BadRequest()
}
await linkMailAddress({
mailAuthToken: req.body.mailAuthToken,
deviceAuthToken: req.body.deviceAuthToken,
parentPasswordSecondHash: req.body.parentPasswordSecondHash,
parentUserId: req.body.parentUserId,
websocket,
database
})
res.json({ ok: true })
} catch (ex) {
next(ex)
}
})
router.post('/remove-device', json(), async (req, res, next) => {
try {
if (!isRemoveDeviceRequest(req.body)) {
throw new BadRequest()
}
await database.transaction(async (transaction) => {
const { deviceEntry } = await assertAuthValidAndReturnDetails({
deviceAuthToken: req.body.deviceAuthToken,
parentId: req.body.parentUserId,
secondPasswordHash: req.body.parentPasswordSecondHash,
transaction
})
await removeDevice({
database,
familyId: deviceEntry.familyId,
deviceId: req.body.deviceId,
websocket,
transaction
})
})
res.json({ ok: true })
} catch (ex) {
next(ex)
}
})
router.post('/create-identity-token', json(), async (req, res, next) => {
try {
if (!isRequestIdentityTokenRequest(req.body)) {
throw new BadRequest()
}
const body = req.body
await database.transaction(async (transaction) => {
const { deviceEntry, parentEntry } = await assertAuthValidAndReturnDetails({
deviceAuthToken: body.deviceAuthToken,
parentId: body.parentUserId,
secondPasswordHash: body.parentPasswordSecondHash,
transaction
})
const token = await createIdentityToken({
purpose: body.purpose,
familyId: deviceEntry.familyId,
userId: parentEntry.userId,
mail: parentEntry.mail
})
res.json({ token })
})
} catch (ex) {
if (ex instanceof MissingSignSecretException) res.sendStatus(404)
else next(ex)
}
})
return router
}