feat(auth): integrate Firebase authentication and update auth flow

- Added Firebase authentication methods for login, signup, and password reset.
- Replaced mock user database with Firebase user management.
- Updated auth store to handle Firebase user state and authentication.
- Implemented middleware for Firebase authentication in RPC routes.
- Enhanced error handling and user feedback with toast notifications.
- Added Toast component for user notifications in the UI.
- Updated API client to include authorization headers for authenticated requests.
- Removed unused CSRF token logic and related code.
This commit is contained in:
2026-01-16 02:55:41 +07:00
parent a6f5ba8c90
commit 02247f9018
16 changed files with 921 additions and 553 deletions

View File

@@ -6,6 +6,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
return {
send: async (data) => {
@@ -14,12 +15,18 @@ export function httpClientAdapter(opts: {
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
const extraHeaders = opts.headers ? await opts.headers() : {};
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
);
} else {
req = new Request(url, {
@@ -27,6 +34,7 @@ export function httpClientAdapter(opts: {
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders
},
credentials: "include",
});

View File

@@ -7,6 +7,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
return {
send: async (data) => {
@@ -19,8 +20,8 @@ export function httpClientAdapter(opts: {
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
);
} else {
req = new Request(url, {

View File

@@ -1,242 +1,22 @@
import { getContext } from "hono/context-storage";
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { HonoVarTypes } from "types";
import { sign, verify } from "hono/jwt";
interface RegisterModel {
username: string;
password: string;
email: string;
}
interface User {
id: string;
username: string;
email: string;
name: string;
}
// Mock user database (in-memory)
const mockUsers: Map<string, { password: string; user: User }> = new Map([
['admin', {
password: 'admin123',
user: {
id: '1',
username: 'admin',
email: 'admin@example.com',
name: 'Admin User'
}
}],
['user@example.com', {
password: 'password',
user: {
id: '2',
username: 'user',
email: 'user@example.com',
name: 'Test User'
}
}]
]);
// CSRF token storage (in-memory, in production use Redis or similar)
const csrfTokens = new Map<string, { token: string; expires: number }>();
// Secret for JWT signing
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function generateCSRFToken(): string {
return crypto.randomUUID();
}
function validateCSRFToken(sessionId: string, token: string): boolean {
const stored = csrfTokens.get(sessionId);
if (!stored) return false;
if (stored.expires < Date.now()) {
csrfTokens.delete(sessionId);
return false;
}
return stored.token === token;
}
const register = async (registerModel: RegisterModel) => {
// Check if user already exists
if (mockUsers.has(registerModel.username) || mockUsers.has(registerModel.email)) {
throw new Error('User already exists');
}
const newUser: User = {
id: crypto.randomUUID(),
username: registerModel.username,
email: registerModel.email,
name: registerModel.username
};
mockUsers.set(registerModel.username, {
password: registerModel.password,
user: newUser
});
mockUsers.set(registerModel.email, {
password: registerModel.password,
user: newUser
});
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: newUser.id,
username: newUser.username,
email: newUser.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: newUser,
csrfToken // Return CSRF token to client for subsequent requests
};
};
const login = async (username: string, password: string) => {
// Try to find user by username or email
const userRecord = mockUsers.get(username);
if (!userRecord) {
throw new Error('Invalid credentials');
}
if (userRecord.password !== password) {
throw new Error('Invalid credentials');
}
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: userRecord.user.id,
username: userRecord.user.username,
email: userRecord.user.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: userRecord.user,
csrfToken // Return CSRF token to client for subsequent requests
};
};
// We can keep checkAuth to return the current user profile from the context
// which is populated by the firebaseAuthMiddleware
async function checkAuth() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (!token) {
const user = context.get('user');
if (!user) {
return { authenticated: false, user: null };
}
try {
const payload = await verify(token, JWT_SECRET) as any;
// Find user
const userRecord = Array.from(mockUsers.values()).find(
record => record.user.id === payload.sub
);
if (!userRecord) {
return { authenticated: false, user: null };
}
return {
authenticated: true,
user: userRecord.user
};
} catch (error) {
return { authenticated: false, user: null };
}
}
async function logout() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (token) {
try {
const payload = await verify(token, JWT_SECRET) as any;
// Remove CSRF token
if (payload.sessionId) {
csrfTokens.delete(payload.sessionId);
}
} catch (error) {
// Token invalid, just delete cookie
}
}
deleteCookie(context, 'auth_token', { path: '/' });
return { success: true };
}
async function getCSRFToken() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (!token) {
throw new Error('Not authenticated');
}
const payload = await verify(token, JWT_SECRET) as any;
const stored = csrfTokens.get(payload.sessionId);
// if (!stored) {
// throw new Error('CSRF token not found');
// }
return { csrfToken: stored?.token || null };
return {
authenticated: true,
user: user
};
}
export const authMethods = {
register,
login,
checkAuth,
logout,
getCSRFToken,
};
export { validateCSRFToken };
};

View File

@@ -6,11 +6,9 @@ import {
import { tinyassert } from "@hiogawa/utils";
import { MiddlewareHandler, type Context, type Next } from "hono";
import { getContext } from "hono/context-storage";
import { csrf } from 'hono/csrf'
// import { adminAuth } from "../../lib/firebaseAdmin";
import { z } from "zod";
import { authMethods } from "./auth";
import { jwt } from "hono/jwt";
import { secret } from "./commom";
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
// import { createElement } from "react";
@@ -222,7 +220,7 @@ const routes = {
),
// access context
components: async () => {},
components: async () => { },
getHomeCourses: async () => {
return listCourses.slice(0, 3);
},
@@ -298,29 +296,40 @@ const routes = {
export type RpcRoutes = typeof routes;
export const endpoint = "/rpc";
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
export const jwtRpc: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent", "login", "register"];
export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
c.set("isPublic", isPublic);
// return await next();
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
return await next();
}
console.log("JWT RPC Middleware:", c.req.path);
const jwtMiddleware = jwt({
secret,
cookie: 'auth_token',
verification: {
aud: "ez.lms_users",
}
})
return jwtMiddleware(c, next)
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
// Option: return 401 or let it pass with no user?
// Old logic seemed to require it for non-public paths.
return c.json({ error: "Unauthorized" }, 401);
}
const token = authHeader.split("Bearer ")[1];
try {
// const decodedToken = await adminAuth.verifyIdToken(token);
// c.set("user", decodedToken);
} catch (error) {
console.error("Firebase Auth Error:", error);
return c.json({ error: "Unauthorized" }, 401);
}
return await next();
}
export const rpcServer = async (c: Context, next: Next) => {
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
return await next();
}
const cert = c.req.header()
const cert = c.req.header()
console.log("RPC Request Path:", c.req.raw.cf);
// if (!cert) return c.text('Forbidden', 403)
const handler = exposeTinyRpc({

View File

@@ -5,15 +5,26 @@ import {
} from "@hiogawa/tiny-rpc";
import type { RpcRoutes } from "./rpc";
import { Result } from "@hiogawa/utils";
import {httpClientAdapter} from "@httpClientAdapter";
import { httpClientAdapter } from "@httpClientAdapter";
// console.log("httpClientAdapter module:", httpClientAdapter.toString());
declare let __host__: string;
const endpoint = "/rpc";
const url = import.meta.env.SSR ? "http://localhost" : "";
const headers: Record<string, string> = {}; // inject headers to demonstrate context
import { auth } from "../lib/firebase";
export const client = proxyTinyRpc<RpcRoutes>({
adapter: httpClientAdapter({
url: url + endpoint,
pathsForGET: [],
headers: async () => {
if (import.meta.env.SSR) return {}; // No client auth on server for now
const user = auth.currentUser;
if (user) {
// Force refresh if needed or just get token
const token = await user.getIdToken();
return { Authorization: `Bearer ${token}` };
}
return {};
}
}),
});

View File

@@ -6,7 +6,7 @@ import { renderSSRHead } from '@unhead/vue/server';
import { buildBootstrapScript, getHrefFromManifest, loadCssByModules } from './lib/manifest';
import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors";
import { jwtRpc, rpcServer } from './api/rpc';
import { firebaseAuthMiddleware, rpcServer } from './api/rpc';
import isMobile from 'is-mobile';
import { useAuthStore } from './stores/auth';
import { cssContent } from './lib/primeCssContent';
@@ -25,7 +25,7 @@ app.use(cors(), async (c, next) => {
};
c.set("isMobile", isMobile({ ua }));
await next();
}, rpcServer);
}, firebaseAuthMiddleware, rpcServer);
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});

47
src/lib/firebase.ts Normal file
View File

@@ -0,0 +1,47 @@
import { initializeApp } from "firebase/app";
import { createUserWithEmailAndPassword, getAuth, GoogleAuthProvider, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup } from "firebase/auth";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts",
authDomain: "trello-7ea39.firebaseapp.com",
projectId: "trello-7ea39",
storageBucket: "trello-7ea39.firebasestorage.app",
messagingSenderId: "321067890572",
appId: "1:321067890572:web:e34e1e657125d37be688a9"
};
// Initialize Firebase
const appFirebase = initializeApp(firebaseConfig);
const provider = new GoogleAuthProvider();
export const auth = getAuth(appFirebase);
export const googleAuth = () => signInWithPopup(auth, provider).then((result) => {
console.log('User signed in:', result.user);
return result;
})
export const emailAuth = (username: string, password: string) => {
return signInWithEmailAndPassword(auth, username, password)
}
export const forgotPassword = (email: string) => {
return sendPasswordResetEmail(auth, email)
.then(() => {
console.log('Password reset email sent');
})
.catch((error) => {
console.error('Error sending password reset email:', error);
throw error;
});
}
export const signUp = (email: string, password: string) => {
return createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
console.log('User signed up:', userCredential.user);
return userCredential.user;
})
.catch((error) => {
console.error('Error signing up:', error);
throw error;
});
}

12
src/lib/firebaseAdmin.ts Normal file
View File

@@ -0,0 +1,12 @@
// import { initializeApp, getApps, cert } from 'firebase-admin/app';
// import { getAuth } from 'firebase-admin/auth';
// import certJson from './cert.json';
// const firebaseAdminConfig = {
// credential: cert(certJson as any)
// };
// if (getApps().length === 0) {
// initializeApp(firebaseAdminConfig);
// }
// export const adminAuth = getAuth();

View File

@@ -1,5 +1,6 @@
<template>
<div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2">
@@ -36,6 +37,13 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
import { forgotPassword } from '@/lib/firebase';
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({
email: ''
});
@@ -48,9 +56,11 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
console.log('Form submitted:', values);
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// Handle actual forgot password logic here
forgotPassword(values.email).then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
}).catch(() => {
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
});
}
};
</script>

View File

@@ -4,8 +4,8 @@
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email or Username</label>
<InputText name="email" type="text" placeholder="admin or user@example.com" fluid
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
<InputText name="email" type="text" placeholder="user@example.com" fluid
:disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
@@ -56,15 +56,6 @@
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
for free</router-link>
</p>
<!-- Hint for demo credentials -->
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-xs text-blue-800 font-medium mb-1">Demo Credentials:</p>
<p class="text-xs text-blue-600">Username: <code class="bg-blue-100 px-1 rounded">admin</code> |
Password: <code class="bg-blue-100 px-1 rounded">admin123</code></p>
<p class="text-xs text-blue-600">Email: <code class="bg-blue-100 px-1 rounded">user@example.com</code> |
Password: <code class="bg-blue-100 px-1 rounded">password</code></p>
</div>
</Form>
</div>
</template>
@@ -104,7 +95,6 @@ const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
};
const loginWithGoogle = () => {
console.log('Login with Google');
// Handle Google login logic here
auth.loginWithGoogle();
};
</script>

View File

@@ -43,6 +43,12 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({
name: '',
email: '',
@@ -59,9 +65,9 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
console.log('Form submitted:', values);
// toast.add({ severity: 'success', summary: 'Success', detail: 'Account created successfully', life: 3000 });
// Handle actual signup logic here
auth.register(values.name, values.email, values.password).catch(() => {
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
});
}
};
</script>

View File

@@ -1,7 +1,9 @@
import { defineStore } from 'pinia';
import { useRouter } from 'vue-router';
import { client } from '@/api/rpcclient';
import { ref } from 'vue';
// import { client } from '@/api/rpcclient'; // client no longer used for auth actions
import { ref, onMounted } from 'vue';
import { emailAuth, signUp, auth, googleAuth } from '@/lib/firebase';
import { onAuthStateChanged, signOut, User as FirebaseUser } from 'firebase/auth';
interface User {
id: string;
@@ -15,42 +17,68 @@ export const useAuthStore = defineStore('auth', () => {
const router = useRouter();
const loading = ref(false);
const error = ref<string | null>(null);
const csrfToken = ref<string | null>(null);
const initialized = ref(false);
// Check auth status on init (reads from cookie)
// Check auth status on init using Firebase observer
async function init() {
if (initialized.value) return;
try {
const response = await client.checkAuth();
if (response.authenticated && response.user) {
user.value = response.user;
// Get CSRF token if authenticated
try {
const csrfResponse = await client.getCSRFToken();
csrfToken.value = csrfResponse.csrfToken;
} catch (e) {
// CSRF token might not be available yet
return new Promise<void>((resolve) => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
if (currentUser) {
user.value = mapFirebaseUser(currentUser);
} else {
user.value = null;
}
}
} catch (e) {
// Not authenticated, that's fine
} finally {
initialized.value = true;
}
initialized.value = true;
resolve();
// We could unsubscribe here if we only want initial load,
// but keeping it listens for changes (token refresh etc)
// However, 'init' usually implies just ONCE waiter.
// For reactivity, user.value is updated.
});
// Note: onAuthStateChanged returns an unsubscribe function.
// If we want to keep listening, we shouldn't unsubscribe immediately,
// but for 'await auth.init()' we just want to wait for the first known state.
});
}
function mapFirebaseUser(fwUser: FirebaseUser): User {
return {
id: fwUser.uid,
username: fwUser.email?.split('@')[0] || 'user', // fallback
email: fwUser.email || '',
name: fwUser.displayName || fwUser.email?.split('@')[0] || 'User'
};
}
async function login(username: string, password: string) {
loading.value = true;
error.value = null;
return client.login(username, password).then((response) => {
user.value = response.user;
csrfToken.value = response.csrfToken;
// Assuming username is email for Firebase, or we need to look it up?
// Firebase works with Email. If input is username, this might fail.
// For now assume email.
return emailAuth(username, password).then((userCredential) => {
user.value = mapFirebaseUser(userCredential.user);
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Login failed';
error.value = 'Login failed';
console.error(e);
error.value = 'Login failed: ' + (e.message || 'Unknown error');
throw e;
}).finally(() => {
loading.value = false;
});
}
async function loginWithGoogle() {
loading.value = true;
error.value = null;
return googleAuth().then((result) => {
user.value = mapFirebaseUser(result.user);
router.push('/');
}).catch((e: any) => {
console.error(e);
error.value = 'Google Login failed';
throw e;
}).finally(() => {
loading.value = false;
@@ -60,13 +88,14 @@ export const useAuthStore = defineStore('auth', () => {
async function register(username: string, email: string, password: string) {
loading.value = true;
error.value = null;
return client.register({ username, email, password }).then((response) => {
user.value = response.user;
csrfToken.value = response.csrfToken;
return signUp(email, password).then((fwUser) => {
// update profile with username?
// updateProfile(fwUser, { displayName: username });
user.value = mapFirebaseUser(fwUser);
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Registration failed';
error.value = 'Registration failed';
console.error(e);
error.value = 'Registration failed: ' + (e.message || 'Unknown error');
throw e;
}).finally(() => {
loading.value = false;
@@ -74,18 +103,17 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout() {
return client.logout().then(() => {
return signOut(auth).then(() => {
user.value = null;
csrfToken.value = null;
router.push('/');
})
}
return { user, loading, error, csrfToken, initialized, init, login, register, logout, $reset: () => {
user.value = null;
loading.value = false;
error.value = null;
csrfToken.value = null;
initialized.value = false;
} };
return {
user, loading, error, initialized, init, login, loginWithGoogle, register, logout, $reset: () => {
user.value = null;
loading.value = false;
error.value = null;
initialized.value = false;
}
};
});

1
src/type.d.ts vendored
View File

@@ -6,5 +6,6 @@ declare module "@httpClientAdapter" {
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
}): TinyRpcClientAdapter;
}