diff --git a/packages/server/api.yaml b/packages/server/api.yaml index e56963b5..f0464018 100644 --- a/packages/server/api.yaml +++ b/packages/server/api.yaml @@ -63,7 +63,7 @@ paths: summary: 로그아웃 description: '로그아웃을 위한 엔드포인트입니다. 로그아웃 이후에는 클라이언트에서 토큰을 폐기해 주세요.' security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] responses: '200': description: OK @@ -108,7 +108,7 @@ paths: schema: type: boolean security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] responses: '200': description: OK @@ -192,7 +192,7 @@ paths: summary: 사물함 대여(일반 학부생용) description: 로그인된 학부생 명의로 사물함을 대여합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: @@ -245,7 +245,7 @@ paths: summary: 사용자 정보 열람 description: 사용자의 정보를 열람합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] parameters: - name: id in: query @@ -284,7 +284,7 @@ paths: summary: 사용자 정보 수정 description: 사용자의 정보를 수정합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: @@ -322,7 +322,7 @@ paths: summary: 사용자 정보 삭제 description: 사용자의 정보를 삭제합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: @@ -360,13 +360,8 @@ paths: summary: 사용자 목록 열람 description: 사용자 목록을 열람합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] parameters: - - name: department - in: query - required: true - schema: - $ref: '#/components/schemas/DepartmentEnum' - name: starts in: query schema: @@ -405,7 +400,7 @@ paths: summary: 사용자 일괄 삽입 description: 사용자를 일괄 삽입합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: @@ -443,7 +438,7 @@ paths: summary: 사용자 일괄 삭제 description: 사용자를 일괄 삭제합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: @@ -481,7 +476,7 @@ paths: summary: 전체 설정 열람 description: 서비스의 전체 설정을 열람합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] responses: '200': description: OK @@ -519,7 +514,7 @@ paths: summary: 설정 수정 description: 서비스 혹은 학부의 설정을 수정합니다. security: - - UserSessionAuth: [] + - UserSessionAuth: [ ] requestBody: content: application/json: diff --git a/packages/server/package.json b/packages/server/package.json index 8ceef828..6fc39b6a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@types/aws-lambda": "^8.10.101", "@types/jsonwebtoken": "^8.5.8", + "@types/lockerweb": "link:..\\types", "@typescript-eslint/eslint-plugin": "^5.30.0", "@typescript-eslint/parser": "^5.30.0", "aws-sdk": "^2.1166.0", diff --git a/packages/server/src/auth/data.ts b/packages/server/src/auth/data.ts index d84236dc..0973b897 100644 --- a/packages/server/src/auth/data.ts +++ b/packages/server/src/auth/data.ts @@ -1,51 +1,73 @@ -import type { UpdateItemInput } from "aws-sdk/clients/dynamodb"; -import { adminId, dynamoDB, TableName } from "../common"; -import { UnauthorizedError } from "../error"; +import type { GetItemInput, UpdateItemInput } from 'aws-sdk/clients/dynamodb'; +import { UnauthorizedError } from '../util/error'; +import { adminId, dynamoDB, TableName } from '../util/database'; +import { fromUserDao } from '../user/data'; + +/* ISSUE/REVOKE TOKEN */ export const revokeToken = async function( - id: string, - token: string + id: string, + token: string ): Promise<{ accessToken: string }> { - const req: UpdateItemInput = { - TableName, - Key: { id: { S: id } }, - UpdateExpression: 'REMOVE accessToken', - ConditionExpression: 'accessToken = :token', - ExpressionAttributeValues: { - ':token': { S: token } - }, - ReturnValues: 'UPDATED_OLD' - }; - const res = await dynamoDB.updateItem(req).promise(); - if (res.Attributes.hasOwnProperty('accessToken')) { - return { accessToken: token }; - } else { - throw new UnauthorizedError(); - } + const req: UpdateItemInput = { + TableName, + Key: { type: { S: 'user' }, id: { S: `${id}` } }, + UpdateExpression: 'REMOVE aT', + ConditionExpression: 'aT = :token', + ExpressionAttributeValues: { + ':token': { S: token } + }, + ReturnValues: 'UPDATED_OLD' + }; + const res = await dynamoDB.updateItem(req).promise(); + if (res.Attributes.hasOwnProperty('aT')) { + return { accessToken: token }; + } else { + throw new UnauthorizedError(); + } }; export const issueToken = async function( - id: string, - token: string + id: string, + token: string ): Promise<{ id: string; expires: number }> { - const expires = Date.now() + 3600 * 1000; - const req: UpdateItemInput = { - TableName, - Key: { id: { S: id } }, - UpdateExpression: 'SET accessToken = :token, expiresOn = :expiresOn', - ExpressionAttributeValues: { - ':token': { S: token }, - ':expiresOn': { N: `${expires}` } - }, - ReturnValues: 'UPDATED_NEW' - }; - if (id !== adminId) { - req.ConditionExpression = 'attribute_exists(is_admin) OR attribute_exists(department)'; - } - const res = await dynamoDB.updateItem(req).promise(); - if (res.Attributes.hasOwnProperty('accessToken')) { - return { id, expires }; - } else { - throw new UnauthorizedError('Unauthorized', { id, expires }); - } -}; \ No newline at end of file + const expires = Date.now() + 3600 * 1000 * 24; + const req: UpdateItemInput = { + TableName, + Key: { type: { S: 'user' }, id: { S: `${id}` } }, + UpdateExpression: 'SET aT = :token, eO = :expiresOn', + ...(id !== adminId && { ConditionExpression: 'attribute_exists(d)' }), + ExpressionAttributeValues: { + ':token': { S: token }, + ':expiresOn': { N: `${expires}` } + }, + ReturnValues: 'UPDATED_NEW' + }; + const res = await dynamoDB.updateItem(req).promise(); + if (res.Attributes.hasOwnProperty('aT')) { + return { id, expires }; + } else { + throw new UnauthorizedError('Unauthorized', { id, expires }); + } +}; + +export async function assertAccessible(id: string, token: string, adminOnly = false): Promise { + const authReq: GetItemInput = { + TableName, + Key: { + module: { S: 'user' }, + dataId: { + S: `${id}` + } + } + }; + const authRes = await dynamoDB.getItem(authReq).promise(); + if ( + authRes.Item.id.S !== `${id}` || + authRes.Item.aT?.S !== token || + (adminOnly && authRes.Item.iA?.BOOL !== true && id !== adminId) + ) { + throw new UnauthorizedError('Unauthorized'); + } + return fromUserDao(authRes.Item as unknown as UserDao); +} \ No newline at end of file diff --git a/packages/server/src/auth/handler/logout.ts b/packages/server/src/auth/handler/logout.ts index a33c44bb..a0c511d7 100644 --- a/packages/server/src/auth/handler/logout.ts +++ b/packages/server/src/auth/handler/logout.ts @@ -1,23 +1,28 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { revokeToken } from "../data"; -import { createResponse } from "../../common"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../env'; +import { assertAccessible, revokeToken } from '../data'; +import { createResponse } from '../../common'; +import { ResponsibleError } from '../../util/error'; export const logoutHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - try { - const payload = jwt.verify(token, JWT_SECRET) as JwtPayload; - const res = await revokeToken(payload.aud as string, token); - return createResponse(200, { success: true, ...res }); - } catch (err) { - const res = { - success: false, - token, - error: 401, - error_description: 'Unauthorized' - }; - return createResponse(401, res); - } + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + try { + const payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + await assertAccessible(payload.aud as string, token); + const res = await revokeToken(payload.aud as string, token); + return createResponse(200, { success: true, ...res }); + } catch (err) { + if (err instanceof ResponsibleError) { + return err.response(); + } + const res = { + success: false, + token, + error: 401, + error_description: 'Unauthorized' + }; + return createResponse(401, res); + } }; \ No newline at end of file diff --git a/packages/server/src/auth/handler/ssu_login.ts b/packages/server/src/auth/handler/ssu_login.ts index aafaaf21..c4672729 100644 --- a/packages/server/src/auth/handler/ssu_login.ts +++ b/packages/server/src/auth/handler/ssu_login.ts @@ -3,18 +3,18 @@ import type { APIGatewayProxyHandler } from 'aws-lambda'; import * as jwt from 'jsonwebtoken'; import { JWT_SECRET } from '../../env'; import { createResponse } from '../../common'; -import { ResponsibleError, UnauthorizedError } from '../../error'; -import { issueToken } from "../data"; +import { ResponsibleError, UnauthorizedError } from '../../util/error'; +import { issueToken } from '../data'; function requestBody(result: string): Promise { return new Promise((resolve, reject) => { https .get(`https://canvas.ssu.ac.kr/learningx/login/from_cc?result=${result}`, (res) => { let body = ''; - res.on('data', function (chunk) { + res.on('data', function(chunk) { body += chunk; }); - res.on('end', function () { + res.on('end', function() { resolve(body); }); }) @@ -46,10 +46,12 @@ export const ssuLoginHandler: APIGatewayProxyHandler = async (event) => { const left = Math.floor((issued.expires - Date.now()) / 1000); const res = { success: true, - id, - access_token: accessToken, - token_type: 'Bearer', - expires_in: left + result: { + id, + accessToken, + tokenType: 'Bearer', + expiresIn: left + } }; return createResponse(200, { success: true, ...res }); } diff --git a/packages/server/src/common.ts b/packages/server/src/common.ts index efb8ea71..d8c5bdf5 100644 --- a/packages/server/src/common.ts +++ b/packages/server/src/common.ts @@ -1,29 +1,6 @@ import type { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; -import lockerData from './lockers.json'; -import AWS from 'aws-sdk'; -import type { ServiceConfigurationOptions } from 'aws-sdk/lib/service'; -import type { - ClientApiVersions, - GetItemInput -} from 'aws-sdk/clients/dynamodb'; -import { UnauthorizedError } from "./error"; -const awsRegion = process.env.AWS_REGION ?? 'ap-southeast-2'; -export const TableName = process.env.TABLE_NAME ?? 'LockerTable'; -export const adminId = process.env.ADMIN_ID ?? '20211561'; - -const options: ServiceConfigurationOptions & ClientApiVersions = { - apiVersion: '2012-08-10', - region: awsRegion -}; - -if (process.env.AWS_SAM_LOCAL) { - options.endpoint = new AWS.Endpoint('http://dynamodb:8000'); -} - -export const dynamoDB = new AWS.DynamoDB(options); - export function createResponse(statusCode: number, body: string | object): APIGatewayProxyResult { const stringifyBody = typeof body === 'string' ? body : JSON.stringify(body); const res: APIGatewayProxyResult = { @@ -39,70 +16,6 @@ export function createResponse(statusCode: number, body: string | object): APIGa return res; } -type LockerMap = { - [floor: string]: { - [section: string]: { - range: number[]; - department: 'E' | 'A' | 'C' | 'S' | 'G'; - }[]; - }; -}; - -export const lockers: LockerMap = lockerData as LockerMap; - -export const assertAdmin = async function(modId: string, token: string) { - const authReq: GetItemInput = { - TableName, - Key: { - id: { - S: modId - } - } - }; - const authRes = await dynamoDB.getItem(authReq).promise(); - if ( - authRes.Item.id.S !== modId || - authRes.Item.accessToken?.S !== token || - (authRes.Item.isAdmin?.BOOL !== true && modId !== adminId) - ) { - throw new UnauthorizedError('Unauthorized'); - } -}; - -export function isValidLocker( - lockerFloor: string, - lockerId: string, - department?: 'E' | 'A' | 'C' | 'S' | 'G' -): boolean { - const parsedLockerId = lockerId.split('-'); - const lockerSectionNum = parseInt(parsedLockerId[1]); - const selectedSections = lockers?.[lockerFloor]?.[parsedLockerId[0]]; - if (parsedLockerId.length !== 2) return false; - if (!selectedSections) return false; - const section = selectedSections.find( - (sect) => - sect.range[0] <= lockerSectionNum && - sect.range[1] >= lockerSectionNum && - (department === undefined || department === sect.department) - ); - return section !== undefined; -} - -export function getLockerDepartment( - lockerFloor: string, - lockerId: string -): 'E' | 'A' | 'C' | 'S' | 'G' { - const parsedLockerId = lockerId.split('-'); - const lockerSectionNum = parseInt(parsedLockerId[1]); - const selectedSections = lockers?.[lockerFloor]?.[parsedLockerId[0]]; - if (parsedLockerId.length !== 2) throw new Error('Given locker is not valid'); - if (!selectedSections) throw new Error('Given locker is not valid'); - const section = selectedSections.find( - (sect) => sect.range[0] <= lockerSectionNum && sect.range[1] >= lockerSectionNum - ); - if (section) return section.department; - else throw new Error('Given locker is not valid'); -} // eslint-disable-next-line @typescript-eslint/require-await export const localCorsHandler: APIGatewayProxyHandler = async () => { diff --git a/packages/server/src/user/data.ts b/packages/server/src/user/data.ts index fcecd027..a377cebf 100644 --- a/packages/server/src/user/data.ts +++ b/packages/server/src/user/data.ts @@ -1,105 +1,168 @@ +/* USER CRUD */ + import type { - BatchWriteItemInput, - ExpressionAttributeValueMap, - GetItemInput, - UpdateItemInput, - WriteRequest + BatchWriteItemInput, + DeleteItemInput, + ExpressionAttributeValueMap, + GetItemInput, + QueryInput, + QueryOutput, + UpdateItemInput, + WriteRequest } from 'aws-sdk/clients/dynamodb'; -import { ResponsibleError, UnauthorizedError } from '../error'; -import { assertAdmin, dynamoDB, TableName } from "../common"; +import { dynamoDB, TableName } from '../util/database'; +import { NotFoundError, ResponsibleError } from '../util/error'; + +export const fromUserDao = (dao: UserDao): User => ({ + id: dao.id.S, + name: dao.n?.S, + isAdmin: dao.iA.BOOL, + department: dao.d?.S, + ...(dao.lI?.S && { lockerId: dao.lI?.S }), + ...(dao.cU?.S && { lockerId: dao.cU?.S }) +}); +export const toUserDao = (user: User): UserDao => ({ + type: { S: 'user' }, + id: { S: `${user.id}` }, + n: { S: user.name }, + iA: { BOOL: user.isAdmin }, + d: { S: user.department }, + ...(user.lockerId && { lockerId: { S: user.lockerId } }), + ...(user.claimedUntil && { cU: { S: user.claimedUntil } }) +}); -type UserInfo = { - id: string; - isAdmin: boolean; - department: 'E' | 'A' | 'C' | 'S' | 'G'; - lockerId?: string; - claimedUntil?: number; +export const getUser = async function(id: string): Promise { + const req: GetItemInput = { + TableName, + Key: { + type: { S: 'user' }, + id: { S: `${id}` } + } + }; + const res = await dynamoDB.getItem(req).promise(); + if (res.Item === undefined) { + throw new NotFoundError(`Cannot find user info of id ${id}`); + } + const dao: UserDao = res.Item as unknown as UserDao; + return fromUserDao(dao); +}; +export const queryUser = async function(startsWith: string): Promise> { + let composedRes: Array = []; + const req: QueryInput = { + TableName, + KeyConditionExpression: '#type = :v1 AND begins_with(#id, :v2)', + ExpressionAttributeNames: { + '#type': 'type', + '#id': 'id' + }, + ExpressionAttributeValues: { + ':v1': { S: 'user' }, + ':v2': { S: `${startsWith}` } + }, + ProjectionExpression: 'id, n, iA, d, lockerId, cU' + }; + let res: QueryOutput; + do { + res = await dynamoDB + .query({ + ...req, + ...(res && res.LastEvaluatedKey && { ExclusiveStartKey: res.LastEvaluatedKey }) + }) + .promise(); + composedRes = [ + ...composedRes, + ...res.Items.map((v) => fromUserDao(v as unknown as UserDao)) + ]; + } while (res.LastEvaluatedKey); + return composedRes; }; -export const getUserInfo = async function(id: string): Promise { - const req: GetItemInput = { - TableName, - Key: { - id: { - S: id - } - } - }; - const res = await dynamoDB.getItem(req).promise(); - const ret: UserInfo = { - id: id, - isAdmin: res.Item.isAdmin?.BOOL ?? false, - department: res.Item.department?.S as 'E' | 'A' | 'C' | 'S' | 'G' - }; - if (res.Item.lockerId?.S) ret.lockerId = res.Item.lockerId.S; - if (res.Item.claimedUntil?.N) ret.claimedUntil = parseInt(res.Item.claimedUntil.N); - return ret; +export const updateUser = async function(info: UserUpdateRequest): Promise { + const attributes: ExpressionAttributeValueMap = {}; + let updateExp = ''; + if (info.name) { + attributes[':name'] = { S: info.name }; + updateExp = 'SET n = :name'; + } + if (info.isAdmin) { + attributes[':isAdmin'] = { BOOL: info.isAdmin }; + updateExp += `${updateExp ? ',' : 'SET'} iA = :userName`; + } + if (info.department) { + attributes[':department'] = { S: info.department }; + updateExp += `${updateExp ? ',' : 'SET'} d = :department`; + } + if (info.lockerId) { + attributes[':lockerId'] = { S: info.lockerId }; + updateExp += `${updateExp ? ',' : 'SET'} lockerId = :lockerId`; + } + if (info.claimedUntil) { + attributes[':claimedUntil'] = { S: info.claimedUntil }; + updateExp += `${updateExp ? ',' : 'SET'} cU = :claimedUntil`; + } + const req: UpdateItemInput = { + TableName, + Key: { + type: { S: 'user' }, + id: { S: `${info.id}` } + }, + UpdateExpression: updateExp, + ExpressionAttributeValues: attributes + }; + await dynamoDB.updateItem(req).promise(); + return info; }; -export const updateUserInfo = async function( - modId: string, - token: string, - info: { id: string; isAdmin?: boolean; department?: string } -): Promise<{ id: string; isAdmin?: boolean; department?: string }> { - await assertAdmin(modId, token); - const attributes: ExpressionAttributeValueMap = {}; - let updateExp = ''; - if (info.isAdmin !== undefined) { - attributes[':isAdmin'] = { BOOL: info.isAdmin }; - updateExp = 'SET isAdmin = :isAdmin'; - } - if (info.department) { - attributes[':department'] = { S: info.department }; - updateExp += `${updateExp ? ',' : 'SET'} department = :department`; - } - const req: UpdateItemInput = { - TableName, - Key: { - id: { S: info.id } - }, - UpdateExpression: updateExp, - ExpressionAttributeValues: attributes - }; - await dynamoDB.updateItem(req).promise(); - const ret: { id: string; isAdmin?: boolean; department?: string } = { id: info.id }; - if (info.isAdmin !== undefined) ret.isAdmin = info.isAdmin; - if (info.department) ret.department = info.department; - return ret; +export const deleteUser = async function(id: string): Promise { + const req: DeleteItemInput = { + TableName, + Key: { + type: { S: 'user' }, + id: { S: id } + } + }; + await dynamoDB.deleteItem(req).promise(); + return id; +}; +export const batchPutUser = async function(infos: Array): Promise> { + if (infos.length === 0) return infos; + if (infos.length > 25) throw new ResponsibleError('Maximum amount of batch creation is 25'); + const requests: WriteRequest[] = infos.map((v: User) => ({ + PutRequest: { + Item: { + ...toUserDao(v) + } + } + })); + const req: BatchWriteItemInput = { + RequestItems: {} + }; + req.RequestItems[TableName] = requests; + const res = await dynamoDB.batchWriteItem(req).promise(); + console.log(res); + return infos; }; -export const batchCreateUserInfo = async function( - modId: string, - token: string, - infos: Array<{ id: string; department: string; isAdmin?: boolean }> -): Promise> { - if (infos.length === 0) return infos; - if (infos.length > 25) throw new ResponsibleError('Maximum amount of batch creation is 25'); - const authReq: GetItemInput = { - TableName, - Key: { - id: { - S: modId - } - } - }; - const authRes = await dynamoDB.getItem(authReq).promise(); - if (authRes.Item.id.S !== modId || authRes.Item.accessToken?.S !== token) { - throw new UnauthorizedError('Unauthorized'); - } - const requests: WriteRequest[] = infos.map((v) => ({ - PutRequest: { - Item: { - id: { S: v.id }, - department: { S: v.department }, - isAdmin: { BOOL: v.isAdmin === undefined ? false : v.isAdmin } - } - } - })); - const req: BatchWriteItemInput = { - RequestItems: { - TableName: requests - } - }; - await dynamoDB.batchWriteItem(req).promise(); - return infos; +export const batchDeleteUser = async function(ids: Array): Promise> { + if (ids.length === 0) return ids; + if (ids.length > 25) throw new ResponsibleError('Maximum amount of batch creation is 25'); + const requests: WriteRequest[] = ids.map((v: string) => ({ + DeleteRequest: { + Key: { + type: { + S: 'user' + }, + id: { + S: `${v}` + } + } + } + })); + const req: BatchWriteItemInput = { + RequestItems: {} + }; + req.RequestItems[TableName] = requests; + const res = await dynamoDB.batchWriteItem(req).promise(); + console.log(res); + return ids; }; \ No newline at end of file diff --git a/packages/server/src/user/handler/batch/delete.ts b/packages/server/src/user/handler/batch/delete.ts index 89586a43..45464da7 100644 --- a/packages/server/src/user/handler/batch/delete.ts +++ b/packages/server/src/user/handler/batch/delete.ts @@ -1,14 +1,63 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { getUserInfo } from "../../data"; -import { createResponse } from "../../../common"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../../env'; +import { createResponse } from '../../../common'; +import { assertAccessible } from '../../../auth/data'; +import { batchDeleteUser } from '../../data'; +import { ResponsibleError } from '../../../util/error'; -export const batchDeleteUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; - return createResponse(200, { - success: true - }); +export const userBatchDeleteHandler: APIGatewayProxyHandler = async (event) => { + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + let data: string[]; + try { + data = JSON.parse(event.body) as string[]; + } catch { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Data body is malformed JSON' + }); + } + if (!data || !Array.isArray(data)) { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Internal error' + }); + } + let payload: JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + } catch { + console.debug('malformed token'); + return createResponse(401, { + success: false, + error: 401, + error_description: 'Unauthorized' + }); + } + let i = 0; + try { + const id = payload.aud as string; + await assertAccessible(id, token, true); + for (i = 0; i < data.length; i += 25) { + const partialData = data.slice(i, i + 25); + await batchDeleteUser(partialData); + } + return createResponse(200, { success: true }); + } catch (e) { + if (e instanceof ResponsibleError) { + e.additionalInfo.failed_data = data.slice(i, data.length); + return e.response(); + } + console.error(e); + const res = { + success: false, + error: 500, + error_description: 'Internal error', + failed_data: JSON.stringify(data.slice(i, data.length)) + }; + return createResponse(500, res); + } }; \ No newline at end of file diff --git a/packages/server/src/user/handler/batch/put.ts b/packages/server/src/user/handler/batch/put.ts index 210ca19a..20328cd4 100644 --- a/packages/server/src/user/handler/batch/put.ts +++ b/packages/server/src/user/handler/batch/put.ts @@ -1,14 +1,63 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { getUserInfo } from "../../data"; -import { createResponse } from "../../../common"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../../env'; +import { createResponse } from '../../../common'; +import { assertAccessible } from '../../../auth/data'; +import { batchPutUser } from '../../data'; +import { ResponsibleError } from '../../../util/error'; -export const batchPutUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; - return createResponse(200, { - success: true - }); +export const userBatchPutHandler: APIGatewayProxyHandler = async (event) => { + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + let data: User[]; + try { + data = JSON.parse(event.body) as User[]; + } catch { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Data body is malformed JSON' + }); + } + if (!data || !Array.isArray(data)) { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Internal error' + }); + } + let payload: JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + } catch { + console.debug('malformed token'); + return createResponse(401, { + success: false, + error: 401, + error_description: 'Unauthorized' + }); + } + let i = 0; + try { + const id = payload.aud as string; + await assertAccessible(id, token, true); + for (i = 0; i < data.length; i += 25) { + const partialData = data.slice(i, i + 25); + await batchPutUser(partialData); + } + return createResponse(200, { success: true }); + } catch (e) { + if (e instanceof ResponsibleError) { + e.additionalInfo.failed_data = data.slice(i, data.length); + return e.response(); + } + console.error(e); + const res = { + success: false, + error: 500, + error_description: 'Internal error', + failed_data: JSON.stringify(data.slice(i, data.length)) + }; + return createResponse(500, res); + } }; \ No newline at end of file diff --git a/packages/server/src/user/handler/delete.ts b/packages/server/src/user/handler/delete.ts index 3633fd00..ae4d3bbd 100644 --- a/packages/server/src/user/handler/delete.ts +++ b/packages/server/src/user/handler/delete.ts @@ -1,13 +1,57 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { createResponse } from "../../common"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../env'; +import { createResponse } from '../../common'; +import { assertAccessible } from '../../auth/data'; +import { deleteUser } from '../data'; +import { ResponsibleError } from '../../util/error'; export const deleteUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; - return createResponse(200, { - success: true - }); + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + let data: UserDeleteRequest; + try { + data = JSON.parse(event.body) as UserDeleteRequest; + } catch { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Data body is malformed JSON' + }); + } + if (!data || !data.id) { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Internal error' + }); + } + let payload: JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + } catch { + console.debug('malformed token'); + return createResponse(401, { + success: false, + error: 401, + error_description: 'Unauthorized' + }); + } + try { + const id = payload.aud as string; + await assertAccessible(id, token, true); + const res = await deleteUser(data.id); + return createResponse(200, { success: true, result: res }); + } catch (e) { + if (e instanceof ResponsibleError) { + return e.response(); + } + console.error(e); + const res = { + success: false, + error: 500, + error_description: 'Internal error' + }; + return createResponse(500, res); + } }; \ No newline at end of file diff --git a/packages/server/src/user/handler/get.ts b/packages/server/src/user/handler/get.ts index 602e568e..43976192 100644 --- a/packages/server/src/user/handler/get.ts +++ b/packages/server/src/user/handler/get.ts @@ -1,25 +1,37 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { createResponse } from "../../common"; -import { getUserInfo } from "../data"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; +import { JWT_SECRET } from '../../env'; +import { createResponse } from '../../common'; +import { getUser } from '../data'; +import { ResponsibleError } from '../../util/error'; export const getUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; - const res = await getUserInfo(id); - if (res.lockerId) { - return createResponse(200, { - success: true, - result: { ...res, claimed: true } - }); - } - return createResponse(200, { - success: true, - result: { - ...res, - claimed: false - } - }); + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + try { + const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; + const result = await getUser(id); + return createResponse(200, { + success: true, + result + }); + } catch (e) { + if (e instanceof ResponsibleError) { + return e.response(); + } + if (e instanceof JsonWebTokenError || e instanceof TokenExpiredError) { + return createResponse(401, { + success: false, + error: 401, + description: 'Unauthorized' + }); + } + console.error(e); + return createResponse(500, { + success: false, + error: 500, + description: 'Internal Error' + }); + } }; \ No newline at end of file diff --git a/packages/server/src/user/handler/query.ts b/packages/server/src/user/handler/query.ts index 3e6eead0..7bc84445 100644 --- a/packages/server/src/user/handler/query.ts +++ b/packages/server/src/user/handler/query.ts @@ -1,13 +1,33 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../env"; -import type { JwtPayload } from "jsonwebtoken"; -import { createResponse } from "../../common"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../env'; +import { createResponse } from '../../common'; +import { assertAccessible } from '../../auth/data'; +import { queryUser } from '../data'; +import { ResponsibleError } from '../../util/error'; export const queryUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; - return createResponse(200, { - success: true - }); + const startsWith = event.queryStringParameters?.starts ?? ''; + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + try { + const id = (jwt.verify(token, JWT_SECRET) as JwtPayload).aud as string; + await assertAccessible(id, token, true); + console.log(event); + const result = await queryUser(startsWith); + return createResponse(200, { + success: true, + result + }); + } catch (e) { + if (e instanceof ResponsibleError) { + return e.response(); + } + console.error(e); + return createResponse(500, { + success: false, + error: 500, + description: 'Internal Error' + }); + } }; \ No newline at end of file diff --git a/packages/server/src/user/handler/update.ts b/packages/server/src/user/handler/update.ts index 0d093123..322a7b6e 100644 --- a/packages/server/src/user/handler/update.ts +++ b/packages/server/src/user/handler/update.ts @@ -1,68 +1,57 @@ -import type { APIGatewayProxyHandler } from "aws-lambda"; -import { createResponse } from "../../common"; -import type { JwtPayload } from "jsonwebtoken"; -import * as jwt from "jsonwebtoken"; -import { JWT_SECRET } from "../../env"; -import { ResponsibleError } from "../../error"; -import { updateUserInfo } from "../data"; +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { createResponse } from '../../common'; +import type { JwtPayload } from 'jsonwebtoken'; +import * as jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../../env'; +import { ResponsibleError } from '../../util/error'; +import { updateUser } from '../data'; +import { assertAccessible } from '../../auth/data'; export const updateUserHandler: APIGatewayProxyHandler = async (event) => { - const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); - let data: { - id: string; - is_admin?: boolean; - department?: string; - }; - - try { - data = JSON.parse(event.body) as { - id: string; - is_admin?: boolean; - department?: string; - }; - } catch { - return createResponse(500, { - success: false, - error: 500, - error_description: 'Data body is malformed JSON' - }); - } - if (!data.id) { - return createResponse(500, { - success: false, - error: 500, - error_description: 'Internal error' - }); - } - let payload: JwtPayload; - try { - payload = jwt.verify(token, JWT_SECRET) as JwtPayload; - } catch { - console.debug('malformed token'); - return createResponse(401, { - success: false, - error: 401, - error_description: 'Unauthorized' - }); - } - try { - const id = payload.aud as string; - const res = await updateUserInfo(id, token, { - id: data.id, - isAdmin: data.is_admin, - department: data.department - }); - return createResponse(200, { success: true, ...res }); - } catch (e) { - if (!(e instanceof ResponsibleError)) { - console.error(e); - const res = { - success: false, - error: 500, - error_description: 'Internal error' - }; - return createResponse(500, res); - } - return e.response(); - } + const token = (event.headers.Authorization ?? '').replace('Bearer ', ''); + let data: UserUpdateRequest; + try { + data = JSON.parse(event.body) as UserUpdateRequest; + } catch { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Data body is malformed JSON' + }); + } + if (!data || !data.id) { + return createResponse(500, { + success: false, + error: 500, + error_description: 'Internal error' + }); + } + let payload: JwtPayload; + try { + payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + } catch { + console.debug('malformed token'); + return createResponse(401, { + success: false, + error: 401, + error_description: 'Unauthorized' + }); + } + try { + const id = payload.aud as string; + await assertAccessible(id, token, true); + await updateUser(data); + return createResponse(200, { success: true }); + } catch (e) { + if (e instanceof ResponsibleError) { + return e.response(); + } + console.error(e); + const res = { + success: false, + error: 500, + error_description: 'Internal error' + }; + return createResponse(500, res); + } }; \ No newline at end of file diff --git a/packages/server/src/util/database.ts b/packages/server/src/util/database.ts new file mode 100644 index 00000000..ea0d30f5 --- /dev/null +++ b/packages/server/src/util/database.ts @@ -0,0 +1,19 @@ +import type { ServiceConfigurationOptions } from 'aws-sdk/lib/service'; +import type { ClientApiVersions } from 'aws-sdk/clients/dynamodb'; +import AWS from 'aws-sdk'; + +const awsRegion = process.env.AWS_REGION ?? 'ap-southeast-2'; +export const TableName = process.env.TABLE_NAME ?? 'LockerTable'; +export const adminId = process.env.ADMIN_ID ?? '20211561'; + +const options: ServiceConfigurationOptions & ClientApiVersions = { + apiVersion: '2012-08-10', + region: awsRegion +}; + +if (process.env.AWS_SAM_LOCAL) { + options.endpoint = new AWS.Endpoint('http://dynamodb:8000'); +} + +export const dynamoDB = new AWS.DynamoDB(options); + diff --git a/packages/server/src/error.ts b/packages/server/src/util/error.ts similarity index 77% rename from packages/server/src/error.ts rename to packages/server/src/util/error.ts index 45d34b75..61d93d1d 100644 --- a/packages/server/src/error.ts +++ b/packages/server/src/util/error.ts @@ -1,5 +1,5 @@ import type { APIGatewayProxyResult } from 'aws-lambda'; -import { createResponse } from './common'; +import { createResponse } from '../common'; export class ResponsibleError extends Error { additionalInfo: Record; @@ -30,6 +30,17 @@ export class UnauthorizedError extends ResponsibleError { } } +export class NotFoundError extends ResponsibleError { + response(): APIGatewayProxyResult { + return createResponse(404, { + success: false, + error: 404, + error_description: this.message, + ...this.additionalInfo + }); + } +} + export class CantClaimError extends ResponsibleError { response(): APIGatewayProxyResult { return createResponse(403, { diff --git a/packages/server/src/util/locker.ts b/packages/server/src/util/locker.ts new file mode 100644 index 00000000..bcd3a38d --- /dev/null +++ b/packages/server/src/util/locker.ts @@ -0,0 +1,47 @@ +import lockerData from '../lockers.json'; + +type LockerMap = { + [floor: string]: { + [section: string]: { + range: number[]; + department: 'E' | 'A' | 'C' | 'S' | 'G'; + }[]; + }; +}; + +export const lockers: LockerMap = lockerData as LockerMap; + +export function isValidLocker( + lockerFloor: string, + lockerId: string, + department?: 'E' | 'A' | 'C' | 'S' | 'G' +): boolean { + const parsedLockerId = lockerId.split('-'); + const lockerSectionNum = parseInt(parsedLockerId[1]); + const selectedSections = lockers?.[lockerFloor]?.[parsedLockerId[0]]; + if (parsedLockerId.length !== 2) return false; + if (!selectedSections) return false; + const section = selectedSections.find( + (sect) => + sect.range[0] <= lockerSectionNum && + sect.range[1] >= lockerSectionNum && + (department === undefined || department === sect.department) + ); + return section !== undefined; +} + +export function getLockerDepartment( + lockerFloor: string, + lockerId: string +): 'E' | 'A' | 'C' | 'S' | 'G' { + const parsedLockerId = lockerId.split('-'); + const lockerSectionNum = parseInt(parsedLockerId[1]); + const selectedSections = lockers?.[lockerFloor]?.[parsedLockerId[0]]; + if (parsedLockerId.length !== 2) throw new Error('Given locker is not valid'); + if (!selectedSections) throw new Error('Given locker is not valid'); + const section = selectedSections.find( + (sect) => sect.range[0] <= lockerSectionNum && sect.range[1] >= lockerSectionNum + ); + if (section) return section.department; + else throw new Error('Given locker is not valid'); +} \ No newline at end of file diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index f9a87346..d0eac370 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -1,5 +1,5 @@ type DaoData = { - type: { S: `user/${string}` | 'config' }; + type: { S: `user` | 'config' }; id: { S: string }; } @@ -10,16 +10,27 @@ type User = { department: string; lockerId?: string; claimedUntil?: string; - accessToken?: string; +} + +type UserUpdateRequest = { + id: string; + name?: string; + isAdmin?: boolean; + department?: string; + lockerId?: string; + claimedUntil?: string; +} + +type UserDeleteRequest = { + id: string; } type UserDao = DaoData & { n: { S: string }; iA: { BOOL: boolean }; d: { S: string }; - lI?: { S: string }; + lockerId?: { S: string }; cU?: { S: string }; - aT?: { S: string }; } type Config = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9f7c962..ad501db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,7 @@ importers: specifiers: '@types/aws-lambda': ^8.10.101 '@types/jsonwebtoken': ^8.5.8 + '@types/lockerweb': link:..\types '@typescript-eslint/eslint-plugin': ^5.30.0 '@typescript-eslint/parser': ^5.30.0 aws-sdk: ^2.1166.0 @@ -76,6 +77,7 @@ importers: devDependencies: '@types/aws-lambda': 8.10.101 '@types/jsonwebtoken': 8.5.8 + '@types/lockerweb': link:../types '@typescript-eslint/eslint-plugin': 5.30.0_5mtqsiui4sk53pmkx7i7ue45wm '@typescript-eslint/parser': 5.30.0_b5e7v2qnwxfo6hmiq56u52mz3e aws-sdk: 2.1166.0