Merge pull request #16 from ForFarmTeam/feature-authen

Add user generation to Oauth flow and middleware to lock some routes
This commit is contained in:
Sirin Puenggun 2025-03-12 15:31:00 +07:00 committed by GitHub
commit 3d020fcd58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 354 additions and 44 deletions

View File

@ -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) {

View File

@ -4,17 +4,18 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"regexp"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
"github.com/forfarm/backend/internal/utilities" "github.com/forfarm/backend/internal/utilities"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
validation "github.com/go-ozzo/ozzo-validation/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func (a *api) registerAuthRoutes(_ chi.Router, api huma.API) { func (a *api) registerAuthRoutes(_ chi.Router, api huma.API) {
tags := []string{"auth"} tags := []string{"auth"}
prefix := "/auth" prefix := "/auth"
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
@ -58,10 +59,37 @@ type RegisterOutput struct {
} }
} }
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) { func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*RegisterOutput, error) {
resp := &RegisterOutput{} 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) _, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
if err == domain.ErrNotFound { if err == domain.ErrNotFound {
@ -78,7 +106,12 @@ func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*Regis
return nil, err 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 { if err != nil {
return nil, err 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) { func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) {
resp := &LoginOutput{} 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) user, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// verify password hash
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,101 @@
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,
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"`
}
}
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")
}
googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken)
if err != nil {
return nil, errors.New("invalid Google ID token")
}
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
}
output := &ExchangeTokenOutput{}
output.Body.JWT = token
output.Body.Email = email
_ = googleUserID // Maybe need in the future
return output, nil
}

View File

@ -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
} }

View 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
}

View File

@ -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} />;
} }

View File

@ -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.");

View File

@ -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,6 +33,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head /> <head />
<SessionProvider> <SessionProvider>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string}>
<ReactQueryProvider> <ReactQueryProvider>
<body className={`${poppins.variable}`}> <body className={`${poppins.variable}`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
@ -41,6 +43,7 @@ export default function RootLayout({
</ThemeProvider> </ThemeProvider>
</body> </body>
</ReactQueryProvider> </ReactQueryProvider>
</GoogleOAuthProvider>
</SessionProvider> </SessionProvider>
</html> </html>
); );

View File

@ -7,7 +7,6 @@ import {
BookOpen, BookOpen,
Bot, Bot,
Command, Command,
Frame,
GalleryVerticalEnd, GalleryVerticalEnd,
Map, Map,
PieChart, PieChart,
@ -47,6 +46,29 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
config?: SidebarConfig; config?: SidebarConfig;
} }
function UserSkeleton() {
return (
<div className="flex items-center space-x-2 animate-pulse">
<div className="w-8 h-8 bg-gray-300 rounded-full" />
<div className="w-24 h-4 bg-gray-300 rounded" />
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function UserErrorFallback({ message }: { message: string }) {
return (
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-red-300 rounded-full flex items-center justify-center">
<span role="img" aria-label="error">
</span>
</div>
<div className="text-sm text-red-600">Failed to load user</div>
</div>
);
}
export function AppSidebar({ config, ...props }: AppSidebarProps) { export function AppSidebar({ config, ...props }: AppSidebarProps) {
const defaultConfig: SidebarConfig = { const defaultConfig: SidebarConfig = {
teams: [ teams: [
@ -90,8 +112,12 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
email: data.user.Email, email: data.user.Email,
avatar: data.user.Avatar || "/avatars/avatar.webp", avatar: data.user.Avatar || "/avatars/avatar.webp",
}); });
} catch (err: any) { } catch (err: unknown) {
if (err instanceof Error) {
setError(err.message); setError(err.message);
} else {
setError("An unexpected error occurred");
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -110,7 +136,9 @@ export function AppSidebar({ config, ...props }: AppSidebarProps) {
<NavCrops crops={sidebarConfig.crops} /> <NavCrops crops={sidebarConfig.crops} />
</div> </div>
</SidebarContent> </SidebarContent>
<SidebarFooter>{loading ? "Loading..." : error ? error : <NavUser user={user} />}</SidebarFooter> <SidebarFooter>
{loading ? <UserSkeleton /> : error ? <UserErrorFallback message={error} /> : <NavUser user={user} />}
</SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
); );

22
frontend/middleware.ts Normal file
View File

@ -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).+)",
],
};

View File

@ -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",

View File

@ -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': {}