feat:google oauth
This commit is contained in:
@@ -25,3 +25,7 @@ POSTGRES_PRISMA_URL=****************************
|
|||||||
NEXT_PUBLIC_STACK_PROJECT_ID=****************************
|
NEXT_PUBLIC_STACK_PROJECT_ID=****************************
|
||||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=****************************************
|
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=****************************************
|
||||||
STACK_SECRET_SERVER_KEY=***********************
|
STACK_SECRET_SERVER_KEY=***********************
|
||||||
|
|
||||||
|
# Google Oauth for google login
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|||||||
3
drizzle/0002_robust_firedrake.sql
Normal file
3
drizzle/0002_robust_firedrake.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "user" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "google_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_google_id_unique" UNIQUE("google_id");
|
||||||
122
drizzle/meta/0002_snapshot.json
Normal file
122
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"id": "277e5a0d-b5f7-40e3-aaf9-c6cd5fe3d0a0",
|
||||||
|
"prevId": "d190d67d-4be7-486a-b23e-9106feca588a",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"name": "age",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1758905260578,
|
"when": 1758905260578,
|
||||||
"tag": "0001_complete_namora",
|
"tag": "0001_complete_namora",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1759502119139,
|
||||||
|
"tag": "0002_robust_firedrake",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@sveltejs/adapter-vercel": "^5.10.2",
|
"@sveltejs/adapter-vercel": "^5.10.2",
|
||||||
|
"arctic": "^3.7.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"svelte-maplibre": "^1.2.1"
|
"svelte-maplibre": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { Button } from '$lib/components/button/index.js';
|
import { Button } from '$lib/components/button/index.js';
|
||||||
import * as Card from '$lib/components/card/index.js';
|
import * as Card from '$lib/components/card/index.js';
|
||||||
import { Label } from '$lib/components/label/index.js';
|
import { Label } from '$lib/components/label/index.js';
|
||||||
@@ -61,6 +62,15 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" class="mt-4 w-full" onclick={() => goto('/login/google')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -82,3 +82,26 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
|||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserFromGoogleId(googleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(googleId: string, name: string) {
|
||||||
|
const userId = generateUserId();
|
||||||
|
const user: table.User = {
|
||||||
|
id: userId,
|
||||||
|
username: name,
|
||||||
|
googleId,
|
||||||
|
passwordHash: null,
|
||||||
|
age: null
|
||||||
|
};
|
||||||
|
await db.insert(table.user).values(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUserId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(15));
|
||||||
|
return encodeBase64url(bytes);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ export const user = pgTable('user', {
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
age: integer('age'),
|
age: integer('age'),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull()
|
passwordHash: text('password_hash'),
|
||||||
|
googleId: text('google_id').unique()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable('session', {
|
export const session = pgTable('session', {
|
||||||
|
|||||||
8
src/lib/server/oauth.ts
Normal file
8
src/lib/server/oauth.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Google } from 'arctic';
|
||||||
|
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';
|
||||||
|
|
||||||
|
export const google = new Google(
|
||||||
|
GOOGLE_CLIENT_ID,
|
||||||
|
GOOGLE_CLIENT_SECRET,
|
||||||
|
'https://sergeno.ziasvannes.tech/login/google/callback'
|
||||||
|
);
|
||||||
@@ -36,6 +36,10 @@ export const actions: Actions = {
|
|||||||
return fail(400, { message: 'Incorrect username or password' });
|
return fail(400, { message: 'Incorrect username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!existingUser.passwordHash) {
|
||||||
|
return fail(400, { message: 'Please sign in with Google for this account' });
|
||||||
|
}
|
||||||
|
|
||||||
const validPassword = await verify(existingUser.passwordHash, password, {
|
const validPassword = await verify(existingUser.passwordHash, password, {
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
|
|||||||
30
src/routes/login/google/+server.ts
Normal file
30
src/routes/login/google/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { generateState, generateCodeVerifier } from 'arctic';
|
||||||
|
import { google } from '$lib/server/oauth';
|
||||||
|
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET(event: RequestEvent): Promise<Response> {
|
||||||
|
const state = generateState();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const url = google.createAuthorizationURL(state, codeVerifier, ['openid', 'profile']);
|
||||||
|
|
||||||
|
event.cookies.set('google_oauth_state', state, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 10, // 10 minutes
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
event.cookies.set('google_code_verifier', codeVerifier, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 10, // 10 minutes
|
||||||
|
sameSite: 'lax'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: url.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
78
src/routes/login/google/callback/+server.ts
Normal file
78
src/routes/login/google/callback/+server.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
generateSessionToken,
|
||||||
|
createSession,
|
||||||
|
setSessionTokenCookie,
|
||||||
|
getUserFromGoogleId,
|
||||||
|
createUser
|
||||||
|
} from '$lib/server/auth';
|
||||||
|
import { google } from '$lib/server/oauth';
|
||||||
|
import { decodeIdToken } from 'arctic';
|
||||||
|
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import type { OAuth2Tokens } from 'arctic';
|
||||||
|
|
||||||
|
export async function GET(event: RequestEvent): Promise<Response> {
|
||||||
|
const code = event.url.searchParams.get('code');
|
||||||
|
const state = event.url.searchParams.get('state');
|
||||||
|
const storedState = event.cookies.get('google_oauth_state') ?? null;
|
||||||
|
const codeVerifier = event.cookies.get('google_code_verifier') ?? null;
|
||||||
|
|
||||||
|
if (code === null || state === null || storedState === null || codeVerifier === null) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== storedState) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: OAuth2Tokens;
|
||||||
|
try {
|
||||||
|
tokens = await google.validateAuthorizationCode(code, codeVerifier);
|
||||||
|
} catch {
|
||||||
|
// Invalid code or client credentials
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = decodeIdToken(tokens.idToken()) as { sub?: string; name?: string };
|
||||||
|
const googleUserId = claims.sub;
|
||||||
|
const username = claims.name;
|
||||||
|
|
||||||
|
if (!googleUserId || !username) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await getUserFromGoogleId(googleUserId);
|
||||||
|
|
||||||
|
if (existingUser !== null) {
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const session = await createSession(sessionToken, existingUser.id);
|
||||||
|
setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: '/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createUser(googleUserId, username);
|
||||||
|
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const session = await createSession(sessionToken, user.id);
|
||||||
|
setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: '/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user