From 164f00459dc314f5109672496dfb599c3bf56f4c Mon Sep 17 00:00:00 2001 From: albdert Date: Thu, 22 Oct 2026 11:21:21 +0200 Subject: [PATCH] bruker registrering og login --- backend/package.json | 3 ++ backend/src/auth.ts | 40 +++++++++++++- backend/src/index.ts | 12 ++++- backend/src/loaders.ts | 5 +- backend/src/resolvers.ts | 78 ++++++++++++++++++++++------ backend/src/schema.ts | 28 ++++++++++ pnpm-lock.yaml | 109 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 254 insertions(+), 21 deletions(-) diff --git a/backend/package.json b/backend/package.json index a0910d3..b8eb2eb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,13 +19,16 @@ "license": "ISC", "dependencies": { "@apollo/server": "^5.5.1", + "bcryptjs": "^3.0.3", "dataloader": "^2.2.3", "dotenv": "^18.0.5", "graphql": "^17.0.2", + "jsonwebtoken": "^9.0.3", "pg": "^8.23.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.19.0", "@types/pg": "^8.23.1", "eslint": "^10.11.0", diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 3e71747..30bdfff 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -1,2 +1,38 @@ -// server jwt secret -// const secret = process.env.JWT_SECRET; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { GraphQLError } from 'graphql'; + +const COST = 12; +// Brukes når brukeren ikke finnes, så login tar like lang tid uansett +const DUMMY_HASH = bcrypt.hashSync('dummy-password', COST); + +const secret = () => process.env.JWT_SECRET as string; + +export function hashPassword(password: string) { + return bcrypt.hash(password, COST); +} + +export function verifyPassword(password: string, hash: string | undefined) { + return bcrypt.compare(password, hash ?? DUMMY_HASH); +} + +export function signToken(userId: string) { + return jwt.sign({}, secret(), { subject: String(userId), expiresIn: '7d' }); +} + +export function userIdFromHeader(header: string | undefined): string | null { + if (!header?.startsWith('Bearer ')) return null; + try { + const payload = jwt.verify(header.slice(7), secret(), { algorithms: ['HS256'] }); + return typeof payload === 'object' && payload.sub ? payload.sub : null; + } catch { + return null; + } +} + +export function requireUser(ctx: { userId: string | null }): string { + if (!ctx.userId) { + throw new GraphQLError('Not logged in', { extensions: { code: 'UNAUTHENTICATED' } }); + } + return ctx.userId; +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index f175d59..25c0de8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,8 @@ import { pool } from './db.js'; import { typeDefs } from './schema.js'; import { resolvers } from './resolvers.js'; import { Context, createLoaders } from './loaders.js'; +import { userIdFromHeader } from './auth.js'; + // apollo-server port number [default port 3001] const port = Number(process.env.PORT ?? 3001); @@ -11,13 +13,21 @@ const port = Number(process.env.PORT ?? 3001); // tester db tilkobling med en query await pool.query('SELECT 1'); +// sjekk at en jasonwebtoken er satt, hvis ikke feil +// denne skal genereres lokalt for utvikling og på vm +// f.eks. med openssl: openssl rand -base64 32 +if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is not set'); + // apollo server instance const server = new ApolloServer({ typeDefs, resolvers }); // pass apolloserver to standaloneserver const { url } = await startStandaloneServer(server, { listen: { port }, - context: async () => ({ loaders: createLoaders() }), + context: async ({ req }) => ({ + loaders: createLoaders(), + userId: userIdFromHeader(req.headers.authorization), + }), }); console.log(`Server ready at: ${url}`); diff --git a/backend/src/loaders.ts b/backend/src/loaders.ts index fd0f960..cfec17c 100644 --- a/backend/src/loaders.ts +++ b/backend/src/loaders.ts @@ -34,4 +34,7 @@ export function createLoaders() { }; } -export type Context = { loaders: ReturnType }; \ No newline at end of file +export type Context = { + loaders: ReturnType; + userId: string | null; +}; \ No newline at end of file diff --git a/backend/src/resolvers.ts b/backend/src/resolvers.ts index 3cb40a5..5f9f586 100644 --- a/backend/src/resolvers.ts +++ b/backend/src/resolvers.ts @@ -1,9 +1,12 @@ import { GraphQLError } from 'graphql/error'; import { decodeCursor, encodeCursor } from './cursor.js'; import { pool } from './db.js'; +import { hashPassword, verifyPassword, signToken } from './auth.js'; import type { Context } from './loaders.js'; // type definitions +type AuthArgs = { username: string; password: string }; + type GamesArgs = { search?: string | null; filter?: GameFilter | null; @@ -22,8 +25,10 @@ type GameFilter = { }; // const vars and func +const USERNAME_REGEX = /^[A-Za-z0-9_]{3,30}$/; const VALID_ID_REGEX = /^\d{1,18}$/; const MAX_PAGE_SIZE = 50; + const escapeChars = (s: string) => s.replace(/[\\%_]/g, '\\$&'); // definerer hvilke kolonner som skal hentes for en generell @@ -78,6 +83,7 @@ const GAMES_WHERE = ` AND ${WHERE_RELEASED_FROM} AND ${WHERE_RELEASED_TO} `; + // SQL sorting med order by // denne variablen mapper en enum til en gitt order // som kan brukes i SQL-spørringen @@ -108,6 +114,59 @@ const SQL_GAME = ` WHERE id = $1 `; +// SQL bruker-autentisering +const SQL_USER = `SELECT id, username FROM users WHERE id = $1`; +const SQL_USER_BY_NAME = ` + SELECT id, username, password_hash FROM users + WHERE lower(username) = lower($1) +`; +const SQL_REGISTER = ` + INSERT INTO users (username, password_hash) VALUES ($1, $2) + RETURNING id, username +`; + +// bruker-funksjoner +const badInput = (message: string) => + new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } }); + +async function register(_parent: unknown, { username, password }: AuthArgs) { + if (!USERNAME_REGEX.test(username)) { + throw badInput('Brukernavn må inneholde 3-30 tegn: bokstaver, tall eller _'); + } + if (password.length < 8 || Buffer.byteLength(password) > 72) { + throw badInput('Passord må inneholde 8-72 tegn'); + } + const hash = await hashPassword(password); + try { + const user = (await pool.query(SQL_REGISTER, [username, hash])).rows[0]; + return { token: signToken(user.id), user }; + } catch (err) { + if ((err as { code?: string }).code === '23505') { + throw badInput('Brukernavn er allerede i bruk...'); + } + throw err; + } +} + +async function login(_parent: unknown, { username, password }: AuthArgs) { + const row = (await pool.query(SQL_USER_BY_NAME, [username])).rows[0]; + const ok = await verifyPassword(password, row?.password_hash); + if (!row || !ok) { + throw new GraphQLError('Ugyldig brukernavn eller passord...', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + return { token: signToken(row.id), user: { id: row.id, username: row.username } }; +} + +async function me(_parent: unknown, _args: unknown, ctx: Context) { + if (!ctx.userId) return null; + return (await pool.query(SQL_USER, [ctx.userId])).rows[0] ?? null; +} + +// erstatter den gamle løsningen med direkte sql og variabler, +// denne gir mer fleksibilitet i hvordan man sorterer +// og fungerer med pagineringen vår function sortSql(sort: GameSort, reverse: boolean) { const { key, cast, dir } = SORTS[sort]; const order = reverse ? (dir === 'DESC' ? 'ASC' : 'DESC') : dir; @@ -149,22 +208,6 @@ function filterValues(args: GamesArgs) { args.filter?.releasedTo ?? null, ]; } -// resolver for koblings-tabellene i databasen -/* -function relation(table: string, junc: string, fk: string) { - const sql = ` - SELECT t.id, t.name - FROM ${junc} j - JOIN ${table} t ON t.id = j.${fk} - WHERE j.game_id = $1 - ORDER BY t.name - `; - return async (game: { id: string }) => { - const res = await pool.query(sql, [game.id]); - return res.rows; - }; -} - */ function relation(key: keyof Context['loaders']) { return (game: { id: string }, _args: unknown, ctx: Context) => @@ -255,7 +298,8 @@ async function platforms() { // resolvers for å hente kjøre spørringer mot databasen export const resolvers = { - Query: { game, games, genres, platforms }, + Query: { game, games, genres, platforms, me }, + Mutation: { register, login }, Game: { genres: relation('genres'), diff --git a/backend/src/schema.ts b/backend/src/schema.ts index d93dd50..54a998a 100644 --- a/backend/src/schema.ts +++ b/backend/src/schema.ts @@ -103,5 +103,33 @@ export const typeDefs = `#graphql genres: [Facet!]! platforms: [Facet!]! + me: User + } + + """ + Bruker definert ved brukerid og brukernavn. + """ + type User { + id: ID! + username: String! + } + + """ + Payload som sendes for å autentisere en bruker, både brukeren sin token + bruker typen skal sendes. + """ + type AuthPayload { + token: String! + user: User! + } + + """ + Mutation for å mutere data i databasen. + Register regestrerer en ny bruker i databasen. + Login autentiserer en bruker. + """ + type Mutation { + register(username: String!, password: String!): AuthPayload! + login(username: String!, password: String!): AuthPayload! } `; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e1bfca..42d2f8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@apollo/server': specifier: ^5.5.1 version: 5.5.1(graphql@17.0.2) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 dataloader: specifier: ^2.2.3 version: 2.2.3 @@ -27,6 +30,9 @@ importers: graphql: specifier: ^17.0.2 version: 17.0.2 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 pg: specifier: ^8.23.1 version: 8.23.1 @@ -34,6 +40,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0) + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^24.19.0 version: 24.19.0 @@ -742,9 +751,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.19.0': resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} @@ -886,6 +901,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -899,6 +918,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -986,6 +1008,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1259,6 +1284,16 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -1344,6 +1379,27 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -2299,8 +2355,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.19.0 + '@types/long@4.0.2': {} + '@types/ms@2.1.0': {} + '@types/node@24.19.0': dependencies: undici-types: 7.24.6 @@ -2467,6 +2530,8 @@ snapshots: baseline-browser-mapping@2.11.26: {} + bcryptjs@3.0.3: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -2493,6 +2558,8 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} cacheable@2.5.0: @@ -2573,6 +2640,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.443: {} @@ -2861,6 +2932,30 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 @@ -2923,6 +3018,20 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash.sortby@4.7.0: {} loglevel@1.9.2: {}