diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..9310b02 --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +NANO_SESSION_SECRET="your-secret" diff --git a/.gitignore b/.gitignore index 43960c5..745264f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,6 @@ web_modules/ # dotenv environment variable files .env -.env.test .env.development.local .env.test.local .env.production.local diff --git a/README.md b/README.md index f2a73a2..c80bef8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ # Nano Session Functions and Classes for handle session with [@nano/sqlite](https://git.node001.net/nano/sqlite) in [Bun](https://bun.sh/). + +## API + +### Session + +``` +import { getOrCreateSqlite } from '@nano/sqlite' +const db = getOrCreateSqlite({ 'name': ':memory:', 'create': true, 'readwrite': true }) +const session = new Session(db) +``` + +#### create(userId: number, agent: string, language: string, ip = '') + +Create a new Session for User. Agent and Language from User will be used to create a Hmac value in the DB. The IP is optional. If +one of these values are changed, the session is invalid. After Creating the session you get a Random Token, and the userId as +hash. UserHash and Token are + +### get(userHash: string, token: string, agent: string, language: string, ip = '') + +Get Session for User. If session has expired, the entry will be removed from Database. + +### destroy(sessionId: number) + +Remove single Session for User with id of Session. + +### destroyAll(userId: number) + +Remove all session for a User with UserId. diff --git a/bun.lockb b/bun.lockb index edcef50..21fc739 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.ts b/index.ts index c4d9452..be5d65c 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,7 @@ import Session from './src/session.ts' import SessionStore from './src/sessionStore.ts' -export default { - 'Session': Session, - 'SessionStore': SessionStore +export { + Session, + SessionStore } diff --git a/package.json b/package.json index e16addb..0d8b71d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "bun test ./test/*" }, "dependencies": { - "@nano/sqlite": "^0.1.0", + "@nano/sqlite": "0.2.0", "dayjs": "^1.11.12" }, "devDependencies": { diff --git a/src/migration/sessions.sql b/src/migration/sessions.sql index 5cd2e35..e1ba2bb 100644 --- a/src/migration/sessions.sql +++ b/src/migration/sessions.sql @@ -1,5 +1,6 @@ CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, + user_id INTEGER, user TEXT, token TEXT, expired_at TEXT, diff --git a/src/session.ts b/src/session.ts index 2624a25..93b53cf 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes } from 'node:crypto' +import { createHmac, createHash, randomBytes } from 'node:crypto' import dayjs from 'dayjs' import SessionStore from './sessionStore.ts' @@ -6,23 +6,47 @@ import SessionStore from './sessionStore.ts' /** * Session * + * @author Björn Hase + * @license http://opensource.org/licenses/MIT The MIT License + * @link https://git.node001.net/nano/session.git * */ class Session { - /** - * - * - */ + // database object private db + // store for sessions + private sessionStore + + // max logins for one user + private maxLogins = 5; + + // duration for expired_at + private duration = 60; + /** * + * @param object db * */ constructor(db: object) { this.db = db this.sessionStore = new SessionStore(db) + + if (!process.env.NANO_SESSION_SECRET) { + throw new Error('NANO_SESSION_SECRET is undefined, please check .env-file') + } + + // if NANO_MAX_LOGINS is set in .env + if (process.env.NANO_SESSION_MAX_LOGINS) { + this.maxLogins = process.env.NANO_SESSION_MAX_LOGINS + } + + // if NANO_SESSION_DURATION is set in .env + if (process.env.NANO_SESSION_DURATION) { + this.duration = process.env.NANO_SESSION_DURATION + } } /** @@ -35,13 +59,14 @@ class Session { * @return object * */ - public async create(userId: number, agent: string, language: string, ip: string): object { + public async create(userId: number, agent: string, language: string, ip = ''): object { - const userHmac = this.createUserHmac(userId) + const userHash = this.createUserHash(userId) + const userHmac = this.createUserHmac(userHash) const users = await this.sessionStore.findOneByUser(userHmac) // if users are not longer can login, drop oldest - if (users && users.length > process.env.NANO_MAX_LOGINS) { + if (users && users.length > this.maxLogins) { const session = await this.sessionStore.dropOldest(userHash) } @@ -52,15 +77,16 @@ class Session { const date = dayjs() await this.sessionStore.create({ + 'user_id': userId, 'user' : userHmac, 'token' : tokenHmac, - 'expired_at': date.add(process.env.NANO_SESSION_DURATION, 'minute').toISOString(), + 'expired_at': date.add(this.duration, 'minute').toISOString(), 'created_at': date.toISOString() }) return { - 'user': userHmac, - 'token': token // raw token goes in cookie + 'user': userHash, + 'token': token // raw token goes in the cookie } } @@ -70,8 +96,8 @@ class Session { * @param object userId * */ - public async destroy(user: object): void { - await this.sessionStore.remove(user.id) + public async destroy(sessionId: number): void { + await this.sessionStore.remove(sessionId) } /** @@ -81,11 +107,11 @@ class Session { * */ public async destroyAll(userId: number): void { - const userHash = this.createUserHmac(userId) - const users = await this.sessionStore.findByUser(userHash) + const userHmac = this.createUserHmac(this.createUserHash(userId)) + const sessions = await this.sessionStore.findByUser(userHmac) - for (const index in users) { - await this.destroy(users[index]) + for (const index in sessions) { + await this.destroy(sessions[index].id) } } @@ -100,21 +126,21 @@ class Session { * @return string * */ - public async get(userId: number, agent: string, language: string, ip: string, token: string): string { - const userHash = this.createUserHmac(userId) - const tokenHash = this.createTokenHmac(token, agent, language, ip) + public async get(userHash: string, token: string, agent: string, language: string, ip = ''): string { + const userHmac = this.createUserHmac(userHash) + const tokenHmac = this.createTokenHmac(token, agent, language, ip) - let user = await this.sessionStore.findOneByUserAndToken(userHash, tokenHash) + let session = await this.sessionStore.findOneByUserAndToken(userHmac, tokenHmac) // if user found, check expired_at, if expired, delete session - if (user && user.expired_at) { - if (dayjs().isAfter(dayjs(user.expired_at))) { - await this.destroy(user) - user = null + if (session && session.expired_at) { + if (dayjs().isAfter(dayjs(session.expired_at))) { + await this.destroy(session.id) + session = null } } - return user + return session } /** @@ -124,10 +150,24 @@ class Session { * @return string * */ - private createUserHmac(userId: number): string { - const hmac = createHmac('sha512', process.env.NANO_SESSION_SECRET) + private createUserHash(userId: number): string { + const hash = createHash('sha256') const buffer = new Buffer(userId) + return hash.update(buffer).digest('hex') + } + + /** + * creating hmac for userId + * + * @param number userId + * @return string + * + */ + private createUserHmac(userHash: string): string { + const hmac = createHmac('sha512', process.env.NANO_SESSION_SECRET) + const buffer = new Buffer(userHash) + return hmac.update(buffer).digest('base64') } @@ -141,7 +181,7 @@ class Session { * @return string * */ - private createTokenHmac(token: string, agent: string, language: string, ip: string): string { + private createTokenHmac(token: string, agent: string, language: string, ip = ''): string { const hmac = createHmac('sha512', process.env.NANO_SESSION_SECRET) const buffer = new Buffer(token + agent + language + ip) diff --git a/src/sessionStore.ts b/src/sessionStore.ts index 0bcda40..4a4b3a4 100644 --- a/src/sessionStore.ts +++ b/src/sessionStore.ts @@ -1,8 +1,11 @@ import { Store } from '@nano/sqlite' /** - * Session + * SessionStore * + * @author Björn Hase + * @license http://opensource.org/licenses/MIT The MIT License + * @link https://git.node001.net/nano/session.git * */ class SessionStore extends Store { @@ -11,25 +14,25 @@ class SessionStore extends Store { super(db, 'sessions') } - public async findOneByUser(user) { + public async findOneByUser(userHmac) { return this.db.query('SELECT * FROM ' + this.tableName + ' WHERE user = $user') .get({ - '$user': user + '$user': userHmac }) } - public async findByUser(user) { + public async findByUser(userHmac) { return this.db.query('SELECT * FROM ' + this.tableName + ' WHERE user = $user') .all({ - '$user': user + '$user': userHmac }) } - public async findOneByUserAndToken(user, token) { + public async findOneByUserAndToken(userHmac, tokenHmac) { return this.db.query('SELECT * FROM ' + this.tableName + ' WHERE user = $user AND token = $token') .get({ - '$user' : user, - '$token' : token + '$user' : userHmac, + '$token' : tokenHmac }) } } diff --git a/test/session.test.ts b/test/session.test.ts index aee5701..ee0f60b 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -7,6 +7,15 @@ import path from 'path' import dotenv from 'dotenv' import Session from './../src/session.ts' +const userHashResult = '6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d' +const userHmacResult = 'qMMo5J3Kq0q3+IPadNretbmitllWakjUTD/6HnTRdSNoifrjkLEf5p6sc0qx2q0bSGTeqXI4OaP5VuUQk0EzuQ==' +const request = { + agent: 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', + agentChrome: 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Chrome/121.0', + language: 'de,en-US;q=0.8,en;q=0.10', + ip: '1.1.1.1' +} + test('session / create', async () => { const db = getOrCreateSqlite({ 'name': ':memory:', 'create': true, 'readwrite': true }) await runMigrationSqlite(path.resolve(__dirname, './../src/migration'), db) @@ -14,9 +23,9 @@ test('session / create', async () => { dotenv.config('./../.env.test') const session = new Session(db) - const result = await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1') + const result = await session.create(1, request.agent, request.language, request.ip) - expect('3qC9SK5Z8XC0DXQXs3Fs5CbK5ZS9YOVZziNT8Ulzc2dehXh9qJNMyoVl6jGJJnbbd77/qPdID2wRDFEzqtKshg==').toEqual(result.user) + expect(result.user).toEqual(userHashResult) }) test('session / find', async () => { @@ -26,11 +35,25 @@ test('session / find', async () => { dotenv.config('./../.env.test') const session = new Session(db) - const result = await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1') + const result = await session.create(1, request.agent, request.language, request.ip) + + const userSession = await session.get(result.user, result.token, request.agent, request.language, request.ip) + + expect(userHmacResult).toEqual(userSession.user) +}) + +test('session / find / change browser', async () => { + const db = getOrCreateSqlite({ 'name': ':memory:', 'create': true, 'readwrite': true }) + await runMigrationSqlite(path.resolve(__dirname, './../src/migration'), db) + + dotenv.config('./../.env.test') + + const session = new Session(db) + const result = await session.create(1, request.agent, request.language, request.ip) - const user = await session.get(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1', result.token) + const userSession = await session.get(result.user, result.token, request.agentChrome, request.language, request.ip) - expect('3qC9SK5Z8XC0DXQXs3Fs5CbK5ZS9YOVZziNT8Ulzc2dehXh9qJNMyoVl6jGJJnbbd77/qPdID2wRDFEzqtKshg==').toEqual(user.user) + expect(null).toEqual(userSession) }) test('session / expired', async () => { @@ -40,14 +63,14 @@ test('session / expired', async () => { dotenv.config('./../.env.test') const session = new Session(db) - const result = await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1') + const result = await session.create(1, request.agent, request.language, request.ip) // change manually session, and set expired_at to const sessionStore = new SessionStore(db) - await sessionStore.update(1, { 'expired_at': dayjs().toISOString() }) + await sessionStore.update(1, { 'expired_at': dayjs().subtract(60, 'minute').toISOString() }) // getting user - const user = await session.get(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1', result.token) + const user = await session.get(result.user, result.token, request.agent, request.language, request.ip) expect(null).toEqual(user) // user not found, because expired user is deleted }) @@ -60,11 +83,11 @@ test('session / destroy all', async () => { const session = new Session(db) - await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.1') - await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.2') - await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.3') - await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.4') - await session.create(1, 'Mozilla/2.0 (X11; Windows x86;) Gecko/20100101 Firefox/120.0', 'de,en-US;q=0.7,en;q=0.3', '1.1.1.5') + await session.create(1, request.agent, request.language, request.ip) + await session.create(1, request.agent, request.language, request.ip) + await session.create(1, request.agent, request.language, request.ip) + await session.create(1, request.agent, request.language, request.ip) + await session.create(1, request.agent, request.language, request.ip) await session.destroyAll(1) const result = db.query("SELECT * FROM sessions").all()