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/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 } diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go new file mode 100644 index 0000000..5d04333 --- /dev/null +++ b/backend/internal/api/oauth.go @@ -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 +} 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 ( -