Skip to content

Commit

Permalink
feat(sessions): add ip and user agent (ua) when login
Browse files Browse the repository at this point in the history
  • Loading branch information
adrienZ committed Sep 1, 2024
1 parent 1d0798c commit 2868765
Showing 9 changed files with 6,655 additions and 29 deletions.
6,546 changes: 6,546 additions & 0 deletions playground/pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions playground/server/api/session.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
const auth = useSlipAuth();
const { id } = await requireUserSession(event);

const session = await auth.getSession(id);

return session;
});
2 changes: 1 addition & 1 deletion playground/server/plugins/database.setup.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ export default defineNitroPlugin(async () => {
const db = useDatabase();

await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_users ("id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP PRIMARY KEY, "email" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP UNIQUE, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_sessions ("id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_auth_users(id))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_sessions ("id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" TEXT, "ua" TEXT, "user_id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_auth_users(id))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_oauth_accounts ("provider_id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "provider_user_id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "user_id" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (provider_id, provider_user_id), FOREIGN KEY (user_id) REFERENCES slip_auth_users(id))`;

const memoryCache = useStorage().mount("slipAuthInMemory", memoryDriver());
13 changes: 6 additions & 7 deletions playground/server/routes/auth/github.ts
Original file line number Diff line number Diff line change
@@ -5,21 +5,20 @@ export default oauthGitHubEventHandler({
async onSuccess(event, { user }) {
const auth = useSlipAuth();

// const ua = getRequestHeader(event, "User-Agent");
// const ip = getRequestIP(event, { xForwardedFor: true }) ?? null;

const sessionFromDb = await auth.registerUserIfMissingInDb({
const [userId, sessionFromDb] = await auth.registerUserIfMissingInDb({
email: user.email,
providerId: "github",
providerUserId: user.id,
ua: getRequestHeader(event, "User-Agent"),
ip: getRequestIP(event),
});

await setUserSession(event, {
user: {
githubId: user.id,
},
expires_at: sessionFromDb.expires_at,
id: sessionFromDb.id,
user: {
id: userId,
},
});
return sendRedirect(event, "/?success=true");
},
26 changes: 19 additions & 7 deletions src/runtime/core/core.ts
Original file line number Diff line number Diff line change
@@ -15,11 +15,15 @@ interface ICreateOrLoginParams {
providerUserId: string
// because our slip is based on unique emails
email: string
ip?: string
ua?: string
}

interface ICreateSessionsParams {
userId: string
expiresAt: number
ip?: string
ua?: string
}

type SessionsTableSelect = ReturnType<typeof getSessionsTableSchema>["$inferSelect"];
@@ -28,9 +32,7 @@ export interface SlipAuthSession extends Pick<SessionsTableSelect, "id" | "expir
}

type UsersTableSelect = ReturnType<typeof getUsersTableSchema>["$inferSelect"];
export interface SlipAuthUser extends UsersTableSelect {
id: string
}
export interface SlipAuthUser extends Pick<UsersTableSelect, "id"> {}

type OAuthAccountsTableSelect = ReturnType<typeof getOAuthAccountsTableSchema>["$inferSelect"];
export interface SlipAuthOauthAccount extends OAuthAccountsTableSelect {
@@ -90,6 +92,10 @@ export class SlipAuthCore {
return randomUUID();
}

getOrm() {
return this.#orm;
}

public async checkDbAndTables(dialect: checkDbAndTablesParameters[1]) {
return checkDbAndTables(this.#db, dialect, this.#tableNames);
}
@@ -102,7 +108,7 @@ export class SlipAuthCore {
*/
public async registerUserIfMissingInDb(
params: ICreateOrLoginParams,
): Promise<SlipAuthSession> {
): Promise<[ string, SlipAuthSession]> {
const existingUser = (await this.#orm.select({
id: this.schemas.users.id,
})
@@ -129,9 +135,11 @@ export class SlipAuthCore {
const sessionFromRegistration = (await this.insertSession({
userId,
expiresAt: Date.now() + this.#sessionMaxAge,
ip: params.ip,
ua: params.ua,
})).at(0);

return sessionFromRegistration as SlipAuthSession;
return [userId, sessionFromRegistration as SlipAuthSession];
}

const existingAccount = (await this.#orm.select().from(this.schemas.oauthAccounts)
@@ -148,21 +156,25 @@ export class SlipAuthCore {
const sessionFromRegistration = await this.insertSession({
userId: existingUser.id,
expiresAt: Date.now() + this.#sessionMaxAge,
ua: params.ua,
ip: params.ip,
});
const { id, expires_at } = sessionFromRegistration[0];
return { id, expires_at };
return [existingUser.id, { id, expires_at }];
}

throw new Error("could not find oauth user");
}

public async insertSession({ userId, expiresAt }: ICreateSessionsParams) {
public async insertSession({ userId, expiresAt, ip, ua }: ICreateSessionsParams) {
const sessionId = this.#createSessionId();
await this.#orm.insert(this.schemas.sessions)
.values({
id: sessionId,
expires_at: expiresAt,
user_id: userId,
ip,
ua,
}).run();

const session = await this.#orm.select().from(this.schemas.sessions).where(
14 changes: 8 additions & 6 deletions src/runtime/core/tests/core.test.ts
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ beforeEach(async () => {
await db.sql`DROP TABLE IF EXISTS slip_users`;

await db.sql`CREATE TABLE IF NOT EXISTS slip_users ("id" TEXT NOT NULL PRIMARY KEY, "email" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(id))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT, FOREIGN KEY (user_id) REFERENCES slip_users(id))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_oauth_accounts ("provider_id" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (provider_id, provider_user_id), FOREIGN KEY (user_id) REFERENCES slip_users(id))`;
});

@@ -49,6 +49,8 @@ const mockedCreateSession = {
expires_at: 914630400000,
id: "randomUUID-2",
user_id: "randomUUID-1",
ip: null,
ua: null,
};

describe("SlipAuthCore", () => {
@@ -66,15 +68,15 @@ describe("SlipAuthCore", () => {
});

it("should insert when database has no users", async () => {
const inserted = await auth.registerUserIfMissingInDb(defaultInsert);
const [_, inserted] = await auth.registerUserIfMissingInDb(defaultInsert);
expect(inserted).toMatchObject(mockedCreateSession);
expect(
db.prepare("SELECT * from slip_users").all(),
).resolves.toHaveLength(1);
});

it("should insert when database has no users", async () => {
const inserted = await auth.registerUserIfMissingInDb(defaultInsert);
const [_, inserted] = await auth.registerUserIfMissingInDb(defaultInsert);
expect(inserted).toMatchObject(mockedCreateSession);

expect(
@@ -83,7 +85,7 @@ describe("SlipAuthCore", () => {
});

it("should throw an error when registering a user with an email in the database and a different provider", async () => {
const inserted = await auth.registerUserIfMissingInDb(defaultInsert);
const [_, inserted] = await auth.registerUserIfMissingInDb(defaultInsert);
const inserted2 = auth.registerUserIfMissingInDb({
email: defaultInsert.email,
providerId: "discord",
@@ -96,8 +98,8 @@ describe("SlipAuthCore", () => {
});

it("should insert twice when database users have different emails", async () => {
const inserted = await auth.registerUserIfMissingInDb(defaultInsert);
const inserted2 = await auth.registerUserIfMissingInDb({
const [_, inserted] = await auth.registerUserIfMissingInDb(defaultInsert);
const [__, inserted2] = await auth.registerUserIfMissingInDb({
email: "email2@test.com",
providerUserId: "azdjazoodncazd",
providerId: defaultInsert.providerId,
2 changes: 2 additions & 0 deletions src/runtime/database/lib/schema.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,8 @@ export const getUsersTableSchema = (tableNames: tableNames) => sqliteTable(table
export const getSessionsTableSchema = (tableNames: tableNames) => sqliteTable(tableNames.sessions, {
id: text("id").primaryKey().notNull(),
expires_at: integer("expires_at").notNull(),
ip: text("ip"),
ua: text("ua"),
user_id: text("user_id")
.references(() => getUsersTableSchema(tableNames).id)
.notNull(),
70 changes: 63 additions & 7 deletions src/runtime/database/tests/sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -355,9 +355,65 @@ describe("sqlite connector", () => {
});
});

describe("ip field", () => {
it("should throw an error when sessions table does not have an ip field", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
sessions: "slip_sessions",
oauthAccounts: "slip_oauth_accounts",
}),
).rejects.toThrowError(
"slip_sessions table must contain a column with name \"ip\"",
);
});

it("should throw an error when sessions table does not have an ip field with type of text", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" INTEGER)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
sessions: "slip_sessions",
oauthAccounts: "slip_oauth_accounts",
}),
).rejects.toThrowError(
"slip_sessions table must contain a column \"ip\" with type \"TEXT\"",
);
});
});

describe("ua field", () => {
it("should throw an error when sessions table does not have an ua field", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" TEXT)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
sessions: "slip_sessions",
oauthAccounts: "slip_oauth_accounts",
}),
).rejects.toThrowError(
"slip_sessions table must contain a column with name \"ua\"",
);
});

it("should throw an error when sessions table does not have an ua field with type of text", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" TEXT, "ua" INTEGER)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
sessions: "slip_sessions",
oauthAccounts: "slip_oauth_accounts",
}),
).rejects.toThrowError(
"slip_sessions table must contain a column \"ua\" with type \"TEXT\"",
);
});
});

describe("user_id field", () => {
it("should throw an error when sessions table does not have a user_id foreign key", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
@@ -370,7 +426,7 @@ describe("sqlite connector", () => {
});

it("should throw an error when sessions table does not have an user_id field with type of text", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" BLOB)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" TEXT, "ua" TEXT, "user_id" BLOB)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
@@ -383,7 +439,7 @@ describe("sqlite connector", () => {
});

it("should throw an error when sessions table does not have an not nullable user_id field", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "ip" TEXT, "ua" TEXT, "user_id" TEXT)`;

await expect(
checkDbAndTables(db, "sqlite", {
@@ -397,7 +453,7 @@ describe("sqlite connector", () => {
});

it("should throw an error when sessions table does not have a user_id foreign key", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT)`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
@@ -411,7 +467,7 @@ describe("sqlite connector", () => {

it("should throw an error when sessions table does not have a user_id foreign key to user table", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS othertable ("id" TEXT)`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES othertable(id))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT, FOREIGN KEY (user_id) REFERENCES othertable(id))`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
@@ -424,7 +480,7 @@ describe("sqlite connector", () => {
});

it("should throw an error when sessions table does not have a user_id foreign key to user table \"id\" column", async () => {
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(email))`;
await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT, FOREIGN KEY (user_id) REFERENCES slip_users(email))`;
await expect(
checkDbAndTables(db, "sqlite", {
users: "slip_users",
@@ -442,7 +498,7 @@ describe("sqlite connector", () => {
const validUsersTableSetup = () =>
db.sql`CREATE TABLE IF NOT EXISTS slip_users ("id" TEXT NOT NULL PRIMARY KEY, "email" TEXT NOT NULL UNIQUE, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
const validSessionsTableSetup = () =>
db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(id))`;
db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT, FOREIGN KEY (user_id) REFERENCES slip_users(id))`;

beforeEach(async () => {
await validUsersTableSetup();
3 changes: 2 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SlipAuthSession, supportedConnectors, tableNames } from "./core/core";
import type { SlipAuthSession, SlipAuthUser, supportedConnectors, tableNames } from "./core/core";

export interface SlipModuleOptions {
/**
@@ -27,4 +27,5 @@ declare module "nuxt/schema" {
declare module "#auth-utils" {

interface UserSession extends Pick<SlipAuthSession, "id" | "expires_at"> {}
interface User extends Pick<SlipAuthUser, "id"> {}
}

0 comments on commit 2868765

Please sign in to comment.