From 5b77d27dcbc0adc4ce47cc4a931bd356caba1d49 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Sun, 9 Mar 2025 23:22:49 +0700 Subject: [PATCH 1/5] feat: add google oauth --- backend/internal/api/api.go | 1 + backend/internal/api/oauth.go | 58 +++++++++++++++++++++++ backend/internal/utilities/jwt.go | 42 +++++++++++----- backend/internal/utilities/oauth.go | 41 ++++++++++++++++ frontend/app/auth/signin/google-oauth.tsx | 52 +++++++++++++++++--- frontend/app/auth/signin/page.tsx | 1 + frontend/app/layout.tsx | 21 ++++---- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 14 ++++++ 9 files changed, 202 insertions(+), 29 deletions(-) create mode 100644 backend/internal/api/oauth.go create mode 100644 backend/internal/utilities/oauth.go diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index cc64d33..3f9b560 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -76,6 +76,7 @@ func (a *api) Routes() *chi.Mux { a.registerAuthRoutes(r, api) a.registerCropRoutes(r, api) a.registerPlantRoutes(r, api) + a.registerOauthRoutes(r, api) }) router.Group(func(r chi.Router) { diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go new file mode 100644 index 0000000..4b61ca7 --- /dev/null +++ b/backend/internal/api/oauth.go @@ -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 +} diff --git a/backend/internal/utilities/jwt.go b/backend/internal/utilities/jwt.go index f5cdc8f..caeba1d 100644 --- a/backend/internal/utilities/jwt.go +++ b/backend/internal/utilities/jwt.go @@ -8,7 +8,8 @@ import ( "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) { 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(), }) - tokenString, err := token.SignedString(deafultSecretKey) + tokenString, err := token.SignedString(defaultSecretKey) if err != nil { return "", err } @@ -25,7 +26,7 @@ func CreateJwtToken(uuid string) (string, error) { } func VerifyJwtToken(tokenString string, customKey ...[]byte) error { - secretKey := deafultSecretKey + secretKey := defaultSecretKey if len(customKey) > 0 { if len(customKey[0]) < 32 { 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 } -// ExtractUUIDFromToken decodes the JWT token using the default secret key, -// and returns the uuid claim contained within the token. -func ExtractUUIDFromToken(tokenString string) (string, error) { +func ExtractUUIDFromToken(tokenString string, customKey ...[]byte) (string, error) { + secretKey := defaultSecretKey + 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) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } - return deafultSecretKey, nil + + return secretKey, nil }) + if err != nil { return "", err } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - if uuid, ok := claims["uuid"].(string); ok { - return uuid, nil - } - return "", errors.New("uuid not found in token") + if !token.Valid { + return "", jwt.ErrSignatureInvalid } - 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 } diff --git a/backend/internal/utilities/oauth.go b/backend/internal/utilities/oauth.go new file mode 100644 index 0000000..6fa7b5e --- /dev/null +++ b/backend/internal/utilities/oauth.go @@ -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 +} diff --git a/frontend/app/auth/signin/google-oauth.tsx b/frontend/app/auth/signin/google-oauth.tsx index 8d5f1da..92d10e4 100644 --- a/frontend/app/auth/signin/google-oauth.tsx +++ b/frontend/app/auth/signin/google-oauth.tsx @@ -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() { - return ( -
- Google Logo - Sign in with Google -
- ); + const router = useRouter(); + const session = useContext(SessionContext); + + const handleLoginSuccess = async (credentialResponse: CredentialResponse) => { + 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 ; } diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index 450ae1e..f3b460e 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -54,6 +54,7 @@ export default function SigninPage() { session!.setUser(values.email); router.push("/setup"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error("Error logging in:", error); setServerError(error.message || "Invalid email or password. Please try again."); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index a1cc0ed..0c71842 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import { SessionProvider } from "@/context/SessionContext"; import ReactQueryProvider from "@/lib/ReactQueryProvider"; +import { GoogleOAuthProvider } from "@react-oauth/google"; const poppins = Poppins({ subsets: ["latin"], @@ -32,15 +33,17 @@ export default function RootLayout({ - - - -
-
{children}
-
-
- -
+ + + + +
+
{children}
+
+
+ +
+
); diff --git a/frontend/package.json b/frontend/package.json index 2bc983b..4da49cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-visually-hidden": "^1.1.2", "@react-google-maps/api": "^2.20.6", + "@react-oauth/google": "^0.12.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 202a360..9abc8cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@react-google-maps/api': specifier: ^2.20.6 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': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) @@ -931,6 +934,12 @@ packages: '@react-google-maps/marker-clusterer@2.20.0': 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': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3423,6 +3432,11 @@ snapshots: '@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': {} '@rushstack/eslint-patch@1.10.5': {} From 69fa65ccf1ad0940687b19ed255a6c2b00abd2f1 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 12 Mar 2025 14:57:45 +0700 Subject: [PATCH 2/5] feat: add validation of input data in auth --- backend/internal/api/auth.go | 66 ++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index b202f83..bb80493 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -4,17 +4,18 @@ import ( "context" "errors" "net/http" + "regexp" "github.com/danielgtaylor/huma/v2" "github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/utilities" "github.com/go-chi/chi/v5" + validation "github.com/go-ozzo/ozzo-validation/v4" "golang.org/x/crypto/bcrypt" ) func (a *api) registerAuthRoutes(_ chi.Router, api huma.API) { tags := []string{"auth"} - prefix := "/auth" huma.Register(api, huma.Operation{ @@ -34,34 +35,61 @@ func (a *api) registerAuthRoutes(_ chi.Router, api huma.API) { type LoginInput struct { Body struct { - Email string `json:"email" example:" Email address of the user"` - Password string `json:"password" example:" Password of the user"` + Email string `json:"email" example:"Email address of the user"` + Password string `json:"password" example:"Password of the user"` } } type LoginOutput struct { Body struct { - Token string `json:"token" example:" JWT token for the user"` + Token string `json:"token" example:"JWT token for the user"` } } type RegisterInput struct { Body struct { - Email string `json:"email" example:" Email address of the user"` - Password string `json:"password" example:" Password of the user"` + Email string `json:"email" example:"Email address of the user"` + Password string `json:"password" example:"Password of the user"` } } type RegisterOutput struct { Body struct { - Token string `json:"token" example:" JWT token for the user"` + Token string `json:"token" example:"JWT token for the user"` } } +func validateEmail(email string) error { + return validation.Validate(email, + validation.Required.Error("email is required"), + validation.Match(regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)).Error("invalid email format"), + ) +} + +func validatePassword(password string) error { + return validation.Validate(password, + validation.Required.Error("password is required"), + validation.Length(8, 0).Error("password must be at least 8 characters long"), + validation.Match(regexp.MustCompile(`[A-Z]`)).Error("password must contain at least one uppercase letter"), + validation.Match(regexp.MustCompile(`[a-z]`)).Error("password must contain at least one lowercase letter"), + validation.Match(regexp.MustCompile(`[0-9]`)).Error("password must contain at least one numeral"), + validation.Match(regexp.MustCompile(`[\W_]`)).Error("password must contain at least one special character"), + ) +} + func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*RegisterOutput, error) { resp := &RegisterOutput{} - // TODO: Validate input data + if input == nil { + return nil, errors.New("invalid input") + } + + if err := validateEmail(input.Body.Email); err != nil { + return nil, err + } + if err := validatePassword(input.Body.Password); err != nil { + return nil, err + } _, err := a.userRepo.GetByEmail(ctx, input.Body.Email) if err == domain.ErrNotFound { @@ -78,7 +106,12 @@ func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*Regis return nil, err } - token, err := utilities.CreateJwtToken(input.Body.Email) + user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) + if err != nil { + return nil, err + } + + token, err := utilities.CreateJwtToken(user.UUID) if err != nil { return nil, err } @@ -93,14 +126,25 @@ func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*Regis func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) { resp := &LoginOutput{} - // TODO: Validate input data + if input == nil { + return nil, errors.New("invalid input") + } + if input.Body.Email == "" { + return nil, errors.New("email field is required") + } + if input.Body.Password == "" { + return nil, errors.New("password field is required") + } + + if err := validateEmail(input.Body.Email); err != nil { + return nil, err + } user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) if err != nil { return nil, err } - // verify password hash if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil { return nil, err } From 9cc07b32df43d85673509f996fd0cae1ec069b5f Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 12 Mar 2025 15:06:25 +0700 Subject: [PATCH 3/5] feat: add user generation logic to oauth login --- backend/internal/api/oauth.go | 59 ++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go index 4b61ca7..5d04333 100644 --- a/backend/internal/api/oauth.go +++ b/backend/internal/api/oauth.go @@ -2,17 +2,19 @@ package api import ( "context" + "crypto/rand" "errors" "net/http" "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/utilities" "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" ) func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) { tags := []string{"oauth"} - huma.Register(apiInstance, huma.Operation{ OperationID: "oauth_exchange", Method: http.MethodPost, @@ -34,8 +36,24 @@ type ExchangeTokenOutput struct { } } -// 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 generateRandomPassword(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}<>?,./" + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + for i, b := range bytes { + bytes[i] = charset[b%byte(len(charset))] + } + return string(bytes), nil +} + +// exchangeHandler assumes the provided access token is a Google ID token. +// It verifies the token with Google, and if the user doesn't exist, +// it creates a new user with a randomly generated password before issuing your JWT. func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) { if input.Body.AccessToken == "" { return nil, errors.New("access token is required") @@ -46,13 +64,38 @@ func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (* return nil, errors.New("invalid Google ID token") } - newJWT, err := utilities.CreateJwtToken(googleUserID) + user, err := a.userRepo.GetByEmail(ctx, email) + if err == domain.ErrNotFound { + newPassword, err := generateRandomPassword(12) + if err != nil { + return nil, err + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + newUser := &domain.User{ + Email: email, + Password: string(hashedPassword), + } + if err := a.userRepo.CreateOrUpdate(ctx, newUser); err != nil { + return nil, err + } + user = *newUser + } else if err != nil { + return nil, err + } + + token, err := utilities.CreateJwtToken(user.UUID) if err != nil { return nil, err } - resp := &ExchangeTokenOutput{} - resp.Body.JWT = newJWT - resp.Body.Email = email - return resp, nil + output := &ExchangeTokenOutput{} + output.Body.JWT = token + output.Body.Email = email + _ = googleUserID // Maybe need in the future + return output, nil } From 5a246a96278681b07e873c7c440764075fd93576 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 12 Mar 2025 15:18:48 +0700 Subject: [PATCH 4/5] feat: add middlware to check auth route --- frontend/middleware.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/middleware.ts diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..5194d9b --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const token = request.cookies.get("token"); + if (!token) { + const url = request.nextUrl.clone(); + url.pathname = "/auth/signin"; + return NextResponse.redirect(url); + } + return NextResponse.next(); +} + +export const config = { + matcher: [ + // This will match all paths that: + // - have at least one character after "/" + // - do NOT start with /_next/static, /_next/image, /favicon.ico, /hub, or /auth. + // (thus "/auth/signin", "/" and any "/hub" route are not processed by this middleware) + "/((?!_next/static|_next/image|favicon.ico|hub|auth).+)", + ], +}; From 3df1456c40240d01d1887c7ba3c55dc8e48b4eb2 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 12 Mar 2025 15:26:19 +0700 Subject: [PATCH 5/5] ui: show skeleton instead of loading message --- frontend/components/sidebar/app-sidebar.tsx | 36 ++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/components/sidebar/app-sidebar.tsx b/frontend/components/sidebar/app-sidebar.tsx index 678b27f..5bdcf40 100644 --- a/frontend/components/sidebar/app-sidebar.tsx +++ b/frontend/components/sidebar/app-sidebar.tsx @@ -7,7 +7,6 @@ import { BookOpen, Bot, Command, - Frame, GalleryVerticalEnd, Map, PieChart, @@ -47,6 +46,29 @@ interface AppSidebarProps extends React.ComponentProps { config?: SidebarConfig; } +function UserSkeleton() { + return ( +
+
+
+
+ ); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function UserErrorFallback({ message }: { message: string }) { + return ( +
+
+ + ⚠️ + +
+
Failed to load user
+
+ ); +} + export function AppSidebar({ config, ...props }: AppSidebarProps) { const defaultConfig: SidebarConfig = { teams: [ @@ -90,8 +112,12 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) { email: data.user.Email, avatar: data.user.Avatar || "/avatars/avatar.webp", }); - } catch (err: any) { - setError(err.message); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred"); + } } finally { setLoading(false); } @@ -110,7 +136,9 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
- {loading ? "Loading..." : error ? error : } + + {loading ? : error ? : } + );