Skip to content

Commit

Permalink
WS-2: Implement mobile sign in (#619)
Browse files Browse the repository at this point in the history
Closes #613
  • Loading branch information
dtinth authored Apr 27, 2024
1 parent 434c73e commit b2796ec
Show file tree
Hide file tree
Showing 21 changed files with 639 additions and 137 deletions.
4 changes: 0 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ jobs:
uses: actions/checkout@v4
- name: pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: node
uses: actions/setup-node@v4
with:
Expand All @@ -35,8 +33,6 @@ jobs:
uses: actions/checkout@v4
- name: pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: node
uses: actions/setup-node@v4
with:
Expand Down
8 changes: 8 additions & 0 deletions src/@types/mongo/DeviceAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ObjectId } from 'mongodb'

export type DeviceAuthorization = {
_id: string
user: ObjectId
signature: string
expiresAt: Date
}
46 changes: 46 additions & 0 deletions src/backend/auth/authenticateDeviceAuthorizationSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { collections } from '$constants/mongo'
import { TRPCError } from '@trpc/server'
import { ObjectId } from 'mongodb'
import { generateMessageHash } from '../signatures/generateSignature'
import { verifySignature } from '../signatures/verifySignature'
import { finalizeAuthentication } from './finalizeAuthentication'

export async function authenticateDeviceAuthorizationSignature(
deviceId: string,
signature: string
) {
const result = await verifySignature(signature)
if (!result.verified) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Signature is not verified',
})
}

const expectedMessage = `mobileAuthorize:${deviceId}`
const expectedMessageHash = generateMessageHash(expectedMessage)
if (result.messageHash !== expectedMessageHash) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Signature is not for mobile authorization',
})
}

if (Date.parse(result.timestamp) < Date.now() - 15 * 60e3) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Signature is expired',
})
}

const userId = result.userId
const user = await collections.users.findOne({ _id: new ObjectId(userId) })
if (!user) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'User not found in database. This should not happen.',
})
}

return finalizeAuthentication(user.uid)
}
20 changes: 20 additions & 0 deletions src/backend/deviceAuthorizations/getDeviceAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { collections } from '$constants/mongo'
import { sha256 } from '$functions/sha256'

export async function getDeviceAuthorization(
deviceIdBasis: string
): Promise<{ found: false } | { found: true; signature: string }> {
const deviceAuthorization = await collections.deviceAuthorizations.findOne({
_id: await sha256(deviceIdBasis),
expiresAt: { $gt: new Date() },
})
if (!deviceAuthorization) {
return {
found: false,
}
}
return {
found: true,
signature: deviceAuthorization.signature,
}
}
42 changes: 42 additions & 0 deletions src/backend/deviceAuthorizations/saveDeviceAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { collections } from '$constants/mongo'
import type { AuthenticatedUser } from '$types/AuthenticatedUser'
import { TRPCError } from '@trpc/server'

export async function saveDeviceAuthorization(
user: AuthenticatedUser | null,
deviceId: string,
signature: string
) {
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User is not authenticated',
})
}

const userDoc = await collections.users.findOne({ uid: user.uid })
if (!userDoc) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'User not found in database. This should not happen.',
})
}

const existingDeviceAuthorization =
await collections.deviceAuthorizations.findOne({
_id: deviceId,
})
if (existingDeviceAuthorization) {
return { expiresAt: existingDeviceAuthorization.expiresAt }
}

const expiresAt = new Date(Date.now() + 10 * 60e3)
await collections.deviceAuthorizations.insertOne({
_id: deviceId,
user: userDoc._id,
signature,
expiresAt,
})

return { expiresAt }
}
85 changes: 78 additions & 7 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { TRPCError, initTRPC } from '@trpc/server'
import { z } from 'zod'
import { exportJWK } from 'jose'
import { createPrivateKey, createPublicKey } from 'crypto'
import { exportJWK } from 'jose'
import { z } from 'zod'

import { privateKey } from '$constants/secrets/privateKey'

import { authenticateDiscord } from './auth/authenticateDiscord'
import { authenticateEventpopUser } from './auth/authenticateEventpopUser'
import { authenticateGitHub } from './auth/authenticateGitHub'
import { authenticateDiscord } from './auth/authenticateDiscord'
import { getAuthenticatedUser } from './auth/getAuthenticatedUser'

import { createAccessQrCode } from './gardenGate/createAccessQrCode'
import { checkAccess } from './gardenGate/checkAccess'
import { pullLogs } from './gardenGate/pullLogs'
import { getJoinedEvents } from './events/getJoinedEvents'
import { authenticateDeviceAuthorizationSignature } from './auth/authenticateDeviceAuthorizationSignature'
import { mintIdToken } from './auth/mintIdToken'
import {
auditInputSchema,
checkOAuthAudit,
recordOAuthAudit,
} from './auth/oAuthAudit'
import { getDeviceAuthorization } from './deviceAuthorizations/getDeviceAuthorization'
import { saveDeviceAuthorization } from './deviceAuthorizations/saveDeviceAuthorization'
import { getJoinedEvents } from './events/getJoinedEvents'
import { checkAccess } from './gardenGate/checkAccess'
import { createAccessQrCode } from './gardenGate/createAccessQrCode'
import { pullLogs } from './gardenGate/pullLogs'
import { generateSignature } from './signatures/generateSignature'
import { verifySignature } from './signatures/verifySignature'

interface BackendContext {
authToken?: string
Expand Down Expand Up @@ -74,6 +79,20 @@ export const appRouter = t.router({
return authenticateEventpopUser(input.code)
}),

signInWithDeviceAuthorizationSignature: t.procedure
.input(
z.object({
deviceId: z.string(),
signature: z.string(),
})
)
.mutation(({ input }) => {
return authenticateDeviceAuthorizationSignature(
input.deviceId,
input.signature
)
}),

linkGitHubAccount: t.procedure
.input(
z.object({
Expand Down Expand Up @@ -108,6 +127,58 @@ export const appRouter = t.router({
}),
}),

signatures: t.router({
createSignature: t.procedure
.input(
z.object({
message: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await getAuthenticatedUser(ctx.authToken)
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User is not authenticated',
})
}
return generateSignature(user, input.message)
}),

verifySignature: t.procedure
.input(
z.object({
signature: z.string(),
})
)
.query(async ({ input }) => {
return verifySignature(input.signature)
}),
}),

deviceAuthorizations: t.router({
saveDeviceAuthorization: t.procedure
.input(
z.object({
deviceId: z.string(),
signature: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await getAuthenticatedUser(ctx.authToken)
return saveDeviceAuthorization(user, input.deviceId, input.signature)
}),
getDeviceAuthorization: t.procedure
.input(
z.object({
deviceIdBasis: z.string(),
})
)
.query(async ({ input }) => {
return getDeviceAuthorization(input.deviceIdBasis)
}),
}),

gardenGate: t.router({
createAccessQrCode: t.procedure.mutation(async ({ ctx }) => {
const user = await getAuthenticatedUser(ctx.authToken)
Expand Down
27 changes: 27 additions & 0 deletions src/backend/signatures/generateSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AuthenticatedUser } from '$types/AuthenticatedUser'
import { createHash, createHmac } from 'crypto'
import { ObjectId } from 'mongodb'

export const generateMessageHash = (msg: string) => {
return createHash('sha256').update(msg).digest('hex').slice(0, 24)
}

export const generateSignature = (user: AuthenticatedUser, msg: string) => {
const key = process.env.SIGN_KEY_01
if (!key) {
throw new Error('SIGN_KEY_01 is not set')
}
const messageHash = generateMessageHash(msg)
const prefix = [
'grtn',
'v1',
messageHash,
user.sub,
new ObjectId().toString(),
].join('_')
const signature = createHmac('sha256', key)
.update(prefix)
.digest('hex')
.slice(0, 24)
return { signature: `${prefix}_${signature}`, messageHash }
}
51 changes: 51 additions & 0 deletions src/backend/signatures/verifySignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createHmac } from 'crypto'
import { ObjectId } from 'mongodb'

export const verifySignature = async (
signature: string
): Promise<
| {
verified: true
messageHash: string
userId: string
timestamp: string
nonce: string
}
| { verified: false; error: string }
> => {
const parts = signature.split('_')
const fail = (message: string) => ({
verified: false as const,
error: message,
})
if (parts[0] !== 'grtn') {
return fail('Invalid signature format')
}
switch (parts[1]) {
case 'v1': {
const key = process.env.SIGN_KEY_01
if (!key) {
throw new Error('SIGN_KEY_01 is not set')
}
const [, , messageHash, userId, nonce, signatureHash] = parts
const prefix = parts.slice(0, 5).join('_')
const signature = createHmac('sha256', key)
.update(prefix)
.digest('hex')
.slice(0, 24)
if (signature !== signatureHash) {
return fail('Signature mismatch')
}
return {
verified: true,
messageHash,
userId,
timestamp: new ObjectId(nonce).getTimestamp().toISOString(),
nonce: nonce,
}
}
default: {
return fail('Invalid signature version')
}
}
}
Loading

0 comments on commit b2796ec

Please sign in to comment.