mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
feat: add google oauth
This commit is contained in:
parent
281c9069f8
commit
5b77d27dcb
@ -76,6 +76,7 @@ func (a *api) Routes() *chi.Mux {
|
|||||||
a.registerAuthRoutes(r, api)
|
a.registerAuthRoutes(r, api)
|
||||||
a.registerCropRoutes(r, api)
|
a.registerCropRoutes(r, api)
|
||||||
a.registerPlantRoutes(r, api)
|
a.registerPlantRoutes(r, api)
|
||||||
|
a.registerOauthRoutes(r, api)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.Group(func(r chi.Router) {
|
router.Group(func(r chi.Router) {
|
||||||
|
|||||||
58
backend/internal/api/oauth.go
Normal file
58
backend/internal/api/oauth.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/forfarm/backend/internal/utilities"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) {
|
||||||
|
tags := []string{"oauth"}
|
||||||
|
|
||||||
|
huma.Register(apiInstance, huma.Operation{
|
||||||
|
OperationID: "oauth_exchange",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/oauth/exchange",
|
||||||
|
Tags: tags,
|
||||||
|
}, a.exchangeHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeTokenInput struct {
|
||||||
|
Body struct {
|
||||||
|
AccessToken string `json:"access_token" example:"Google ID token obtained after login"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangeTokenOutput struct {
|
||||||
|
Body struct {
|
||||||
|
JWT string `json:"jwt" example:"Fresh JWT for frontend authentication"`
|
||||||
|
Email string `json:"email" example:"Email address of the user"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeHandler now assumes the provided access token is a Google ID token.
|
||||||
|
// It verifies the token with Google and then generates your own JWT.
|
||||||
|
func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) {
|
||||||
|
if input.Body.AccessToken == "" {
|
||||||
|
return nil, errors.New("access token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid Google ID token")
|
||||||
|
}
|
||||||
|
|
||||||
|
newJWT, err := utilities.CreateJwtToken(googleUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &ExchangeTokenOutput{}
|
||||||
|
resp.Body.JWT = newJWT
|
||||||
|
resp.Body.Email = email
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@ -8,7 +8,8 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
var deafultSecretKey = []byte(config.JWT_SECRET_KEY)
|
// TODO: Change later
|
||||||
|
var defaultSecretKey = []byte(config.JWT_SECRET_KEY)
|
||||||
|
|
||||||
func CreateJwtToken(uuid string) (string, error) {
|
func CreateJwtToken(uuid string) (string, error) {
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
@ -16,7 +17,7 @@ func CreateJwtToken(uuid string) (string, error) {
|
|||||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||||
})
|
})
|
||||||
|
|
||||||
tokenString, err := token.SignedString(deafultSecretKey)
|
tokenString, err := token.SignedString(defaultSecretKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -25,7 +26,7 @@ func CreateJwtToken(uuid string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func VerifyJwtToken(tokenString string, customKey ...[]byte) error {
|
func VerifyJwtToken(tokenString string, customKey ...[]byte) error {
|
||||||
secretKey := deafultSecretKey
|
secretKey := defaultSecretKey
|
||||||
if len(customKey) > 0 {
|
if len(customKey) > 0 {
|
||||||
if len(customKey[0]) < 32 {
|
if len(customKey[0]) < 32 {
|
||||||
return errors.New("provided key is too short, minimum length is 32 bytes")
|
return errors.New("provided key is too short, minimum length is 32 bytes")
|
||||||
@ -52,25 +53,40 @@ func VerifyJwtToken(tokenString string, customKey ...[]byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractUUIDFromToken decodes the JWT token using the default secret key,
|
func ExtractUUIDFromToken(tokenString string, customKey ...[]byte) (string, error) {
|
||||||
// and returns the uuid claim contained within the token.
|
secretKey := defaultSecretKey
|
||||||
func ExtractUUIDFromToken(tokenString string) (string, error) {
|
if len(customKey) > 0 {
|
||||||
|
if len(customKey[0]) < 32 {
|
||||||
|
return "", errors.New("provided key is too short, minimum length is 32 bytes")
|
||||||
|
}
|
||||||
|
secretKey = customKey[0]
|
||||||
|
}
|
||||||
|
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, jwt.ErrSignatureInvalid
|
return nil, jwt.ErrSignatureInvalid
|
||||||
}
|
}
|
||||||
return deafultSecretKey, nil
|
|
||||||
|
return secretKey, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
if !token.Valid {
|
||||||
if uuid, ok := claims["uuid"].(string); ok {
|
return "", jwt.ErrSignatureInvalid
|
||||||
return uuid, nil
|
|
||||||
}
|
|
||||||
return "", errors.New("uuid not found in token")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errors.New("invalid token claims")
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("unable to parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := claims["uuid"].(string)
|
||||||
|
if !ok || userID == "" {
|
||||||
|
return "", errors.New("uuid claim is missing or invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/internal/utilities/oauth.go
Normal file
41
backend/internal/utilities/oauth.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package utilities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GoogleTokenInfo struct {
|
||||||
|
Sub string `json:"sub"` // Unique Google user identifier.
|
||||||
|
Email string `json:"email"` // The user's email address.
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractGoogleUserID(idToken string) (string, string, error) {
|
||||||
|
if idToken == "" {
|
||||||
|
return "", "", errors.New("provided id token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", idToken)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("error verifying Google ID token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("tokeninfo endpoint returned unexpected status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenInfo GoogleTokenInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
|
||||||
|
return "", "", fmt.Errorf("error decoding token info response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenInfo.Sub == "" {
|
||||||
|
return "", "", errors.New("Google token missing 'sub' claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenInfo.Sub, tokenInfo.Email, nil
|
||||||
|
}
|
||||||
@ -1,10 +1,48 @@
|
|||||||
import Image from "next/image";
|
import React, { useContext } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { GoogleLogin, CredentialResponse } from "@react-oauth/google";
|
||||||
|
import { SessionContext } from "@/context/SessionContext";
|
||||||
|
|
||||||
|
interface OAuthExchangeData {
|
||||||
|
jwt: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function GoogleSigninButton() {
|
export function GoogleSigninButton() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<div className="flex items-center justify-center gap-3 w-full py-2 px-4 rounded-full border border-border bg-gray-100 dark:bg-slate-700 hover:bg-gray-200 dark:hover:bg-slate-600 transition-colors cursor-pointer">
|
const session = useContext(SessionContext);
|
||||||
<Image src="/google-logo.png" alt="Google Logo" width={35} height={35} className="object-contain" />
|
|
||||||
<span className="font-medium text-gray-800 dark:text-gray-100">Sign in with Google</span>
|
const handleLoginSuccess = async (credentialResponse: CredentialResponse) => {
|
||||||
</div>
|
if (!credentialResponse.credential) {
|
||||||
);
|
console.error("No credential returned from Google");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exchangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/oauth/exchange`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ access_token: credentialResponse.credential }),
|
||||||
|
});
|
||||||
|
if (!exchangeRes.ok) {
|
||||||
|
throw new Error("Exchange token request failed");
|
||||||
|
}
|
||||||
|
const exchangeData: OAuthExchangeData = await exchangeRes.json();
|
||||||
|
const jwt = exchangeData.jwt;
|
||||||
|
const email = exchangeData.email;
|
||||||
|
|
||||||
|
session!.setToken(jwt);
|
||||||
|
session!.setUser(email);
|
||||||
|
|
||||||
|
router.push("/farms");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during token exchange:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginError = () => {
|
||||||
|
console.error("Google login failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
return <GoogleLogin onSuccess={handleLoginSuccess} onError={handleLoginError} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export default function SigninPage() {
|
|||||||
session!.setUser(values.email);
|
session!.setUser(values.email);
|
||||||
|
|
||||||
router.push("/setup");
|
router.push("/setup");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error logging in:", error);
|
console.error("Error logging in:", error);
|
||||||
setServerError(error.message || "Invalid email or password. Please try again.");
|
setServerError(error.message || "Invalid email or password. Please try again.");
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
|
|
||||||
import { SessionProvider } from "@/context/SessionContext";
|
import { SessionProvider } from "@/context/SessionContext";
|
||||||
import ReactQueryProvider from "@/lib/ReactQueryProvider";
|
import ReactQueryProvider from "@/lib/ReactQueryProvider";
|
||||||
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
|
||||||
const poppins = Poppins({
|
const poppins = Poppins({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@ -32,15 +33,17 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head />
|
<head />
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ReactQueryProvider>
|
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string}>
|
||||||
<body className={`${poppins.variable}`}>
|
<ReactQueryProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<body className={`${poppins.variable}`}>
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<div className="flex-1 bg-background">{children}</div>
|
<div className="relative flex min-h-screen flex-col">
|
||||||
</div>
|
<div className="flex-1 bg-background">{children}</div>
|
||||||
</ThemeProvider>
|
</div>
|
||||||
</body>
|
</ThemeProvider>
|
||||||
</ReactQueryProvider>
|
</body>
|
||||||
|
</ReactQueryProvider>
|
||||||
|
</GoogleOAuthProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.2",
|
"@radix-ui/react-visually-hidden": "^1.1.2",
|
||||||
"@react-google-maps/api": "^2.20.6",
|
"@react-google-maps/api": "^2.20.6",
|
||||||
|
"@react-oauth/google": "^0.12.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
|||||||
@ -65,6 +65,9 @@ importers:
|
|||||||
'@react-google-maps/api':
|
'@react-google-maps/api':
|
||||||
specifier: ^2.20.6
|
specifier: ^2.20.6
|
||||||
version: 2.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 2.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@react-oauth/google':
|
||||||
|
specifier: ^0.12.1
|
||||||
|
version: 0.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.16
|
specifier: ^0.5.16
|
||||||
version: 0.5.16(tailwindcss@3.4.17)
|
version: 0.5.16(tailwindcss@3.4.17)
|
||||||
@ -931,6 +934,12 @@ packages:
|
|||||||
'@react-google-maps/marker-clusterer@2.20.0':
|
'@react-google-maps/marker-clusterer@2.20.0':
|
||||||
resolution: {integrity: sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==}
|
resolution: {integrity: sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==}
|
||||||
|
|
||||||
|
'@react-oauth/google@0.12.1':
|
||||||
|
resolution: {integrity: sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@ -3423,6 +3432,11 @@ snapshots:
|
|||||||
|
|
||||||
'@react-google-maps/marker-clusterer@2.20.0': {}
|
'@react-google-maps/marker-clusterer@2.20.0': {}
|
||||||
|
|
||||||
|
'@react-oauth/google@0.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@rushstack/eslint-patch@1.10.5': {}
|
'@rushstack/eslint-patch@1.10.5': {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user