Files
timelimit-server/src/function/authentication/login-by-mail.ts
T

157 lines
4.2 KiB
TypeScript

/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2021 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 { Forbidden, Gone, TooManyRequests, Unauthorized } from 'http-errors'
import { Database } from '../../database'
import { sendAuthenticationMail } from '../../util/mail'
import { areWordSequencesEqual, randomWords } from '../../util/random-words'
import { checkMailSendLimit } from '../../util/ratelimit-authmail'
import { generateAuthToken } from '../../util/token'
import { createAuthTokenByMailAddress } from './index'
export const sendLoginCode = async ({ mail, deviceAuthToken, locale, database }: {
mail: string
deviceAuthToken?: string
locale: string
database: Database
// no transaction here because this is directly called from an API endpoint
}): Promise<{ mailLoginToken: string }> => {
let deviceName = null
if (deviceAuthToken !== undefined) {
const info = await database.transaction(async (transaction) => {
const deviceEntryUnsafe = await database.device.findOne({
where: { deviceAuthToken },
attributes: ['familyId', 'name'],
transaction
})
if (!deviceEntryUnsafe) {
throw new Unauthorized()
}
const deviceEntry = {
familyId: deviceEntryUnsafe.familyId,
name: deviceEntryUnsafe.name
}
const userEntryCounter = await database.user.count({
where: {
familyId: deviceEntry.familyId,
mail
},
transaction
})
if (userEntryCounter === 1) {
return { deviceName: deviceEntry.name }
} else {
// do not show the device name if it is from another family
// otherwise third parties could chose a part of the content of the mail
return { deviceName: null }
}
})
deviceName = info.deviceName
}
try {
await checkMailSendLimit(mail)
} catch (ex) {
throw new TooManyRequests()
}
const mailLoginToken = generateAuthToken()
const code = randomWords(3)
await sendAuthenticationMail({
receiver: mail,
code,
locale,
deviceName
})
await database.transaction(async (transaction) => {
await database.mailLoginToken.create({
mailLoginToken,
receivedCode: code,
mail,
createdAt: Date.now().toString(10),
remainingAttempts: 3,
locale
}, { transaction })
})
return {
mailLoginToken
}
}
// 403 Forbidden = receivedCode is invalid
// 410 Gone = mailLoginToken is invalid or expired
export const signInByMailCode = async ({ mailLoginToken, receivedCode, database }: {
mailLoginToken: string
receivedCode: string
database: Database
// no transaction here because this is directly called from an API endpoint
}): Promise<{ mailAuthToken: string }> => {
return database.transaction(async (transaction) => {
const entry = await database.mailLoginToken.findOne({
where: {
mailLoginToken
},
transaction
})
if ((!entry) || entry.remainingAttempts === 0) {
throw new Gone()
}
if (!areWordSequencesEqual(entry.receivedCode, receivedCode)) {
entry.remainingAttempts--
await entry.save({ transaction })
if (entry.remainingAttempts === 0) {
throw new Gone()
} else {
throw new Forbidden()
}
}
const counter = await database.mailLoginToken.destroy({
where: {
mailLoginToken
},
transaction
})
if (counter !== 1) {
throw new Gone()
}
const mailAuthToken = await createAuthTokenByMailAddress({
mail: entry.mail,
locale: entry.locale,
database,
transaction
})
return { mailAuthToken }
})
}