Add new purchase API

This commit is contained in:
Jonas Lochmann 2022-09-26 02:00:00 +02:00
parent 35acd05d08
commit 58a6359a3e
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
18 changed files with 534 additions and 22 deletions

View file

@ -89,3 +89,41 @@ If there was nothing found for the mail address: HTTP status code 409 Conflict
### see
- [premium concept](../concept/premium.md)
## POST /admin/unlock-premium-v2
Use this to unlock all features for one user for a specified duration.
### request
request properties: ``purchaseToken`` and ``purchaseId``
- ``purchasetoken`` is a string which the client shows at the purchase screen
- ``purchaseId`` is the ID that is used at the bill
### response
The response contains the following properties:
- ``ok`` (boolean)
- ``error``
- string
- set if and only if ``ok`` is false
- possible values
- ``token invalid``
- ``illegal state``
- ``purchase id already used``
- ``detail``
- optional string
- should be shown to the support
- ``lastPurchase``
- optional object
- should be shown to the support
- ``wasAlreadyExecuted`` (boolean, set if and only if ``ok`` is true)
If the request was malformed: HTTP status code 400 Bad Request
Using the same ``purchaseId`` twice results in:
- ``wasAlreadyExecuted`` if the familyId is unchanged
- ``error`` = ``purchase id already used`` otherwise

View file

@ -0,0 +1,35 @@
{
"additionalProperties": false,
"type": "object",
"properties": {
"purpose": {
"type": "string",
"enum": [
"purchase"
]
},
"familyId": {
"type": "string"
},
"userId": {
"type": "string"
},
"mail": {
"type": "string"
},
"exp": {
"type": "number"
}
},
"required": [
"exp",
"familyId",
"mail",
"purpose",
"userId"
],
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IdentityTokenPayload",
"$id": "https://timelimit.io/IdentityTokenPayload"
}

View file

@ -16,6 +16,8 @@
* [FinishPurchaseByGooglePlayRequest](./finishpurchasebygoogleplayrequest.md) `https://timelimit.io/FinishPurchaseByGooglePlayRequest`
* [IdentityTokenPayload](./identitytokenpayload.md) `https://timelimit.io/IdentityTokenPayload`
* [LinkParentMailAddressRequest](./linkparentmailaddressrequest.md) `https://timelimit.io/LinkParentMailAddressRequest`
* [MailAuthTokenRequestBody](./mailauthtokenrequestbody.md) `https://timelimit.io/MailAuthTokenRequestBody`

View file

@ -0,0 +1,15 @@
# Untitled undefined type in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/definitions
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## definitions Type
unknown

View file

@ -0,0 +1,15 @@
# Untitled number in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/properties/exp
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## exp Type
`number`

View file

@ -0,0 +1,15 @@
# Untitled string in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/properties/familyId
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## familyId Type
`string`

View file

@ -0,0 +1,15 @@
# Untitled string in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/properties/mail
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## mail Type
`string`

View file

@ -0,0 +1,23 @@
# Untitled string in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/properties/purpose
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## purpose Type
`string`
## purpose Constraints
**enum**: the value of this property must be equal to one of the following values:
| Value | Explanation |
| :----------- | :---------- |
| `"purchase"` | |

View file

@ -0,0 +1,15 @@
# Untitled string in IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload#/properties/userId
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :---------------------- | :---------------- | :-------------------- | :------------------ | :-------------------------------------------------------------------------------------------- |
| Can be instantiated | No | Unknown status | Unknown identifiability | Forbidden | Allowed | none | [IdentityTokenPayload.schema.json\*](IdentityTokenPayload.schema.json "open original schema") |
## userId Type
`string`

View file

@ -0,0 +1,125 @@
# IdentityTokenPayload Schema
```txt
https://timelimit.io/IdentityTokenPayload
```
| Abstract | Extensible | Status | Identifiable | Custom Properties | Additional Properties | Access Restrictions | Defined In |
| :------------------ | :--------- | :------------- | :----------- | :---------------- | :-------------------- | :------------------ | :------------------------------------------------------------------------------------------ |
| Can be instantiated | Yes | Unknown status | No | Forbidden | Forbidden | none | [IdentityTokenPayload.schema.json](IdentityTokenPayload.schema.json "open original schema") |
## IdentityTokenPayload Type
`object` ([IdentityTokenPayload](identitytokenpayload.md))
# IdentityTokenPayload Properties
| Property | Type | Required | Nullable | Defined by |
| :-------------------- | :------- | :------- | :------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
| [purpose](#purpose) | `string` | Required | cannot be null | [IdentityTokenPayload](identitytokenpayload-properties-purpose.md "https://timelimit.io/IdentityTokenPayload#/properties/purpose") |
| [familyId](#familyid) | `string` | Required | cannot be null | [IdentityTokenPayload](identitytokenpayload-properties-familyid.md "https://timelimit.io/IdentityTokenPayload#/properties/familyId") |
| [userId](#userid) | `string` | Required | cannot be null | [IdentityTokenPayload](identitytokenpayload-properties-userid.md "https://timelimit.io/IdentityTokenPayload#/properties/userId") |
| [mail](#mail) | `string` | Required | cannot be null | [IdentityTokenPayload](identitytokenpayload-properties-mail.md "https://timelimit.io/IdentityTokenPayload#/properties/mail") |
| [exp](#exp) | `number` | Required | cannot be null | [IdentityTokenPayload](identitytokenpayload-properties-exp.md "https://timelimit.io/IdentityTokenPayload#/properties/exp") |
## purpose
`purpose`
* is required
* Type: `string`
* cannot be null
* defined in: [IdentityTokenPayload](identitytokenpayload-properties-purpose.md "https://timelimit.io/IdentityTokenPayload#/properties/purpose")
### purpose Type
`string`
### purpose Constraints
**enum**: the value of this property must be equal to one of the following values:
| Value | Explanation |
| :----------- | :---------- |
| `"purchase"` | |
## familyId
`familyId`
* is required
* Type: `string`
* cannot be null
* defined in: [IdentityTokenPayload](identitytokenpayload-properties-familyid.md "https://timelimit.io/IdentityTokenPayload#/properties/familyId")
### familyId Type
`string`
## userId
`userId`
* is required
* Type: `string`
* cannot be null
* defined in: [IdentityTokenPayload](identitytokenpayload-properties-userid.md "https://timelimit.io/IdentityTokenPayload#/properties/userId")
### userId Type
`string`
## mail
`mail`
* is required
* Type: `string`
* cannot be null
* defined in: [IdentityTokenPayload](identitytokenpayload-properties-mail.md "https://timelimit.io/IdentityTokenPayload#/properties/mail")
### mail Type
`string`
## exp
`exp`
* is required
* Type: `number`
* cannot be null
* defined in: [IdentityTokenPayload](identitytokenpayload-properties-exp.md "https://timelimit.io/IdentityTokenPayload#/properties/exp")
### exp Type
`number`
# IdentityTokenPayload Definitions

View file

@ -42,7 +42,8 @@ const types = [
'RequestIdentityTokenRequest',
'RequestWithAuthToken',
'SendMailLoginCodeRequest',
'SignInByMailCodeRequest'
'SignInByMailCodeRequest',
'IdentityTokenPayload'
]
const docOnlyTypes = [

View file

@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* 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
@ -18,11 +18,13 @@
import { json } from 'body-parser'
import { Router } from 'express'
import { BadRequest, Conflict } from 'http-errors'
import * as Sequelize from 'sequelize'
import { Database } from '../database'
import { addPurchase } from '../function/purchase'
import { addPurchase, canDoNextPurchase } from '../function/purchase'
import { getStatusMessage, setStatusMessage } from '../function/statusmessage'
import { EventHandler } from '../monitoring/eventhandler'
import { generatePurchaseId } from '../util/token'
import { verifyIdentitifyToken, TokenValidationException } from '../util/identity-token'
import { WebsocketApi } from '../websocket'
export const createAdminRouter = ({ database, websocket, eventHandler }: {
@ -120,7 +122,8 @@ export const createAdminRouter = ({ database, websocket, eventHandler }: {
database,
familyId: userEntry.familyId,
type,
transactionId: 'manual-' + type + '-' + generatePurchaseId(),
service: 'directpurchase',
transactionId: 'legacyunlock-' + type + '-' + generatePurchaseId(),
websocket,
transaction
})
@ -132,5 +135,149 @@ export const createAdminRouter = ({ database, websocket, eventHandler }: {
}
})
router.post('/unlock-premium-v2', json(), async (req, res, next) => {
try {
if (
typeof req.body !== 'object' ||
typeof req.body.purchaseToken !== 'string' ||
typeof req.body.purchaseId !== 'string'
) {
throw new BadRequest()
}
const purchaseToken: string = req.body.purchaseToken
const purchaseId: string = req.body.purchaseId
const tokenContent = await verifyIdentitifyToken(purchaseToken)
if (tokenContent.purpose !== 'purchase') {
res.json({ ok: false, error: 'token invalid', detail: 'wrong purpose' })
return
}
const response = await database.transaction(async (transaction) => {
const userValid = await database.user.count({
where: {
familyId: tokenContent.familyId,
userId: tokenContent.userId,
mail: tokenContent.mail,
type: 'parent'
},
transaction
})
if (!userValid) return {
ok: false,
error: 'token invalid',
detail: 'user not found'
}
let mailToReturn: string
if (tokenContent.mail !== '') mailToReturn = tokenContent.mail
else {
const userEntryWithMail = await database.user.findOne({
where: {
familyId: tokenContent.familyId,
mail: {
[Sequelize.Op.ne]: ''
},
type: 'parent'
},
transaction
})
if (!userEntryWithMail) return {
ok: false,
error: 'illegal state',
detail: 'no user with mail found'
}
mailToReturn = userEntryWithMail.mail
}
let wasAlreadyExecuted: boolean
const oldPurchaseByPurchaseId = await database.purchase.findOne({
where: {
service: 'directpurchase',
transactionId: purchaseId
}
})
if (oldPurchaseByPurchaseId === null) wasAlreadyExecuted = false
else if (oldPurchaseByPurchaseId.familyId === tokenContent.familyId) wasAlreadyExecuted = true
else return {
ok: false,
error: 'purchase id already used'
}
if (!wasAlreadyExecuted) {
const familyEntry = await database.family.findOne({
where: {
familyId: tokenContent.familyId
},
transaction
})
if (!familyEntry) return {
ok: false,
error: 'family not found'
}
const canDoPurchase = canDoNextPurchase({ fullVersionUntil: parseInt(familyEntry.fullVersionUntil) })
if (!canDoPurchase) {
const lastPurchase = await database.purchase.findOne({
where: {
familyId: tokenContent.familyId
},
transaction,
order: [['loggedAt', 'DESC']],
limit: 1
})
return {
ok: false,
error: 'can not renew now',
lastPurchase: lastPurchase ? {
service: lastPurchase.service,
transactionId: lastPurchase.transactionId,
timestamp: parseInt(lastPurchase.loggedAt),
timestring: new Date(parseInt(lastPurchase.loggedAt)).toISOString()
} : undefined
}
}
await addPurchase({
database,
familyId: tokenContent.familyId,
type: 'year',
service: 'directpurchase',
transactionId: purchaseId,
websocket,
transaction
})
}
return {
ok: true,
mail: mailToReturn,
wasAlreadyExecuted
}
})
res.json(response)
} catch (ex) {
if (ex instanceof TokenValidationException) res.json({
ok: false,
error: 'token invalid',
detail: ex.message
})
else next(ex)
}
})
return router
}

View file

@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* 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
@ -122,6 +122,7 @@ export const createPurchaseRouter = ({ database, websocket }: {
database,
familyId: deviceEntry.familyId,
type,
service: 'googleplay',
transactionId: orderId,
websocket,
transaction

View file

@ -162,5 +162,16 @@ export interface SignInByMailCodeRequest {
receivedCode: string
}
export interface IdentityTokenCreatePayload {
purpose: 'purchase'
familyId: string
userId: string
mail: string
}
export type IdentityTokenPayload = IdentityTokenCreatePayload & {
exp: number
}
export { SerializedParentAction, SerializedChildAction, SerializedAppLogicAction } from '../action/serialization'
export { ServerDataStatus } from '../object/serverdatastatus'

View file

@ -1,5 +1,5 @@
// tslint:disable
import { ClientPushChangesRequest, ClientPullChangesRequest, MailAuthTokenRequestBody, CreateFamilyByMailTokenRequest, SignIntoFamilyRequest, RecoverParentPasswordRequest, RegisterChildDeviceRequest, SerializedParentAction, SerializedAppLogicAction, SerializedChildAction, CreateRegisterDeviceTokenRequest, CanDoPurchaseRequest, FinishPurchaseByGooglePlayRequest, LinkParentMailAddressRequest, UpdatePrimaryDeviceRequest, RemoveDeviceRequest, RequestIdentityTokenRequest, RequestWithAuthToken, SendMailLoginCodeRequest, SignInByMailCodeRequest } from './schema'
import { ClientPushChangesRequest, ClientPullChangesRequest, MailAuthTokenRequestBody, CreateFamilyByMailTokenRequest, SignIntoFamilyRequest, RecoverParentPasswordRequest, RegisterChildDeviceRequest, SerializedParentAction, SerializedAppLogicAction, SerializedChildAction, CreateRegisterDeviceTokenRequest, CanDoPurchaseRequest, FinishPurchaseByGooglePlayRequest, LinkParentMailAddressRequest, UpdatePrimaryDeviceRequest, RemoveDeviceRequest, RequestIdentityTokenRequest, RequestWithAuthToken, SendMailLoginCodeRequest, SignInByMailCodeRequest, IdentityTokenPayload } from './schema'
import Ajv from 'ajv'
const ajv = new Ajv()
@ -3451,3 +3451,36 @@ export const isSignInByMailCodeRequest: (value: unknown) => value is SignInByMai
"definitions": definitions,
"$schema": "http://json-schema.org/draft-07/schema#"
})
export const isIdentityTokenPayload: (value: unknown) => value is IdentityTokenPayload = ajv.compile({
"additionalProperties": false,
"type": "object",
"properties": {
"purpose": {
"type": "string",
"enum": [
"purchase"
]
},
"familyId": {
"type": "string"
},
"userId": {
"type": "string"
},
"mail": {
"type": "string"
},
"exp": {
"type": "number"
}
},
"required": [
"exp",
"familyId",
"mail",
"purpose",
"userId"
],
"definitions": definitions,
"$schema": "http://json-schema.org/draft-07/schema#"
})

View file

@ -1,6 +1,6 @@
/*
* server component for the TimeLimit App
* Copyright (C) 2019 - 2020 Jonas Lochmann
* 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
@ -21,7 +21,7 @@ import { SequelizeAttributes } from './types'
export interface PurchaseAttributes {
familyId: string
service: 'googleplay'
service: 'googleplay' | 'directpurchase'
transactionId: string
type: 'month' | 'year'
loggedAt: string
@ -37,7 +37,7 @@ export type PurchaseModelStatic = typeof Sequelize.Model & {
export const attributes: SequelizeAttributes<PurchaseAttributes> = {
familyId: { ...familyIdColumn },
service: {
...createEnumColumn(['googleplay']),
...createEnumColumn(['googleplay', 'directpurchase']),
primaryKey: true
},
transactionId: {

View file

@ -24,16 +24,15 @@ const day = 1000 * 60 * 60 * 24
const month = day * 31
const year = day * 366
export const addPurchase = async ({ database, familyId, type, transactionId, websocket, transaction }: {
export const addPurchase = async ({ database, familyId, type, service, transactionId, websocket, transaction }: {
database: Database
familyId: string
type: 'month' | 'year'
service: 'googleplay' | 'directpurchase'
transactionId: string
websocket: WebsocketApi
transaction: Transaction
}) => {
const service = 'googleplay'
const oldPurchaseEntry = await database.purchase.findOne({
where: {
service,

View file

@ -15,21 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { SignJWT } from 'jose'
import { SignJWT, jwtVerify } from 'jose'
import { config } from '../config'
import { IdentityTokenPayload, IdentityTokenCreatePayload } from '../api/schema'
import { isIdentityTokenPayload } from '../api/validator'
export async function createIdentityToken({ purpose, familyId, userId, mail }: {
purpose: string
familyId: string
userId: string
mail: string
}) {
if (config.signSecret === '') throw new MissingSignSecretException()
export async function createIdentityToken({ purpose, familyId, userId, mail }: IdentityTokenCreatePayload) {
const jwt = await new SignJWT({ purpose, familyId, userId, mail })
.setExpirationTime('7d')
.setProtectedHeader({ alg: 'HS512' })
.sign(Buffer.from(config.signSecret, 'utf8'))
.sign(getSignSecret())
return Buffer.from(jwt, 'ascii')
.toString('base64')
@ -38,4 +33,31 @@ export async function createIdentityToken({ purpose, familyId, userId, mail }: {
.join('\n')
}
export async function verifyIdentitifyToken(token: string): Promise<IdentityTokenPayload> {
try {
const { payload } = await jwtVerify(
Buffer.from(token, 'base64').toString('ascii'),
getSignSecret(),
{ algorithms: ['HS512'] }
)
if (!isIdentityTokenPayload(payload)) throw new BadPayloadException()
return payload
} catch (ex) {
if (ex instanceof TokenValidationException) throw ex
else if (ex instanceof Error) throw new TokenValidationException(ex.message)
else throw ex
}
}
function getSignSecret(): Buffer {
if (config.signSecret === '') throw new MissingSignSecretException()
return Buffer.from(config.signSecret, 'utf8')
}
export class MissingSignSecretException extends Error {}
export class TokenValidationException extends Error {}
class BadPayloadException extends TokenValidationException { constructor() { super('bad payload') } }