From 48ff5dbb5a6ba5c633db7c0682031bbcdddeae5a Mon Sep 17 00:00:00 2001 From: pedro Date: Wed, 29 Apr 2026 11:47:43 -0300 Subject: [PATCH] Add Google OAuth login --- .env.example | 4 + README.md | 7 + backend/controllers/auth_controller.go | 191 +++++++++++++++++++++ backend/daos/mocks/user_dao_mock.go | 9 + backend/daos/user_dao.go | 10 ++ backend/go.mod | 7 +- backend/go.sum | 4 + backend/models/user.go | 1 + backend/services/auth_service.go | 122 +++++++++++++ backend/services/auth_service_test.go | 157 +++++++++++++++++ docker-compose.yml | 3 + frontend/src/App.tsx | 2 + frontend/src/assets/google-logo.svg | 1 + frontend/src/locales/en.json | 8 +- frontend/src/locales/pt.json | 8 +- frontend/src/pages/GoogleOAuthCallback.tsx | 65 +++++++ frontend/src/pages/Login.tsx | 20 ++- frontend/src/pages/Registro.tsx | 24 ++- frontend/src/services/auth.ts | 7 +- 19 files changed, 638 insertions(+), 12 deletions(-) create mode 100644 frontend/src/assets/google-logo.svg create mode 100644 frontend/src/pages/GoogleOAuthCallback.tsx diff --git a/.env.example b/.env.example index 45e88c1..17bd1f6 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ JWT_SECRET=dev-only-change-me JWT_ACCESS_EXPIRE=1h JWT_REFRESH_EXPIRE=720h TMDB_API_TOKEN= +FRONTEND_ORIGIN=http://localhost:5173 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback diff --git a/README.md b/README.md index d341bb9..7215b93 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A collaborative movie list web app for couples and friends to track movies they - **Backend**: Go 1.24 + Gin framework - **Database**: MySQL + GORM ORM - **Authentication**: JWT with refresh token rotation +- **OAuth**: Google OAuth web-server flow - **Drag & Drop**: @dnd-kit library - **External APIs**: TMDB (movies, TV shows, watch providers) @@ -53,6 +54,9 @@ Services: The compose setup mounts `./backend` and `./frontend` into their containers, so local source edits are picked up by the development servers. The backend runs `go run .`; restart the backend container after Go code changes if needed. Frontend changes are handled by Vite HMR. Set `TMDB_API_TOKEN` in `.env` to enable movie search, watch providers, and recommendations. +To enable Google sign-in locally, create a Google OAuth web client with redirect URI +`http://localhost:8080/auth/google/callback`, then set `GOOGLE_CLIENT_ID` and +`GOOGLE_CLIENT_SECRET` in `.env`. Useful commands: @@ -78,6 +82,9 @@ DB_DSN=user:password@tcp(localhost:3306)/database_name JWT_SECRET=your_secret_key TMDB_API_TOKEN=your_tmdb_api_key FRONTEND_ORIGIN=http://localhost:5173 +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback PORT=8080 ``` diff --git a/backend/controllers/auth_controller.go b/backend/controllers/auth_controller.go index a5d6a80..f50544d 100644 --- a/backend/controllers/auth_controller.go +++ b/backend/controllers/auth_controller.go @@ -1,14 +1,24 @@ package controllers import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" "net/http" + "net/url" + "os" "strconv" + "strings" "time" "github.com/8bury/list2gether/middleware" "github.com/8bury/list2gether/services" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" ) type AuthController struct { @@ -21,6 +31,8 @@ func NewAuthController(router *gin.Engine, service services.AuthService, authMid group := router.Group("/auth") group.POST("/register", c.register) group.POST("/login", c.login) + group.GET("/google/login", c.googleLogin) + group.GET("/google/callback", c.googleCallback) group.POST("/refresh", c.refresh) group.POST("/logout", c.logout) @@ -29,6 +41,14 @@ func NewAuthController(router *gin.Engine, service services.AuthService, authMid return c } +type googleUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + type registerRequest struct { Username string `json:"username"` Email string `json:"email"` @@ -61,6 +81,16 @@ func (a *AuthController) register(c *gin.Context) { } user, err := a.service.Register(req.Username, req.Email, req.Password) if err != nil { + if errors.Is(err, services.ErrGoogleLoginRequired) { + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusConflict, gin.H{ + "error": err.Error(), + "code": "GOOGLE_LOGIN_REQUIRED", + "details": []string{}, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + return + } respondValidationError(c, []string{err.Error()}) return } @@ -99,6 +129,102 @@ func (a *AuthController) login(c *gin.Context) { }) } +func (a *AuthController) googleLogin(c *gin.Context) { + oauthConfig, err := googleOAuthConfig() + if err != nil { + respondOAuthError(c, http.StatusServiceUnavailable, "Google login is not configured") + return + } + state, err := generateOAuthState() + if err != nil { + respondOAuthError(c, http.StatusInternalServerError, "Failed to start Google login") + return + } + http.SetCookie(c.Writer, oauthStateCookie(c, state, 600)) + c.Redirect(http.StatusFound, oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)) +} + +func (a *AuthController) googleCallback(c *gin.Context) { + frontendOrigin := getFrontendOrigin() + callbackURL := frontendOrigin + "/oauth/google/callback" + clearOAuthStateCookie(c) + + expectedState, err := c.Cookie("oauth_state") + if err != nil || expectedState == "" || c.Query("state") == "" || c.Query("state") != expectedState { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Invalid Google login state")) + return + } + if oauthErr := c.Query("error"); oauthErr != "" { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape(oauthErr)) + return + } + code := c.Query("code") + if code == "" { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Missing Google authorization code")) + return + } + + oauthConfig, err := googleOAuthConfig() + if err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Google login is not configured")) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + token, err := oauthConfig.Exchange(ctx, code) + if err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not exchange Google authorization code")) + return + } + + client := oauthConfig.Client(ctx, token) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://openidconnect.googleapis.com/v1/userinfo", nil) + if err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not fetch Google profile")) + return + } + res, err := client.Do(req) + if err != nil || res == nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not fetch Google profile")) + return + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not fetch Google profile")) + return + } + var profile googleUserInfo + if err := json.NewDecoder(res.Body).Decode(&profile); err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not read Google profile")) + return + } + + user, accessToken, refreshToken, expiresIn, accessExp, err := a.service.LoginWithGoogle(services.GoogleProfile{ + Subject: profile.Sub, + Email: profile.Email, + EmailVerified: profile.EmailVerified, + Name: profile.Name, + Picture: profile.Picture, + }) + if err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape(err.Error())) + return + } + + userJSON, err := json.Marshal(user) + if err != nil { + c.Redirect(http.StatusFound, callbackURL+"#error="+url.QueryEscape("Could not complete Google login")) + return + } + values := url.Values{} + values.Set("access_token", accessToken) + values.Set("refresh_token", refreshToken) + values.Set("expires_in", strconv.FormatInt(expiresIn, 10)) + values.Set("access_token_expires_at", strconv.FormatInt(accessExp, 10)) + values.Set("user", string(userJSON)) + c.Redirect(http.StatusFound, callbackURL+"#"+values.Encode()) +} + func (a *AuthController) refresh(c *gin.Context) { var req refreshRequest if err := c.ShouldBindJSON(&req); err != nil || req.RefreshToken == "" { @@ -215,3 +341,68 @@ func respondTokenInvalid(c *gin.Context) { "timestamp": time.Now().UTC().Format(time.RFC3339), }) } + +func googleOAuthConfig() (*oauth2.Config, error) { + clientID := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")) + clientSecret := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_SECRET")) + redirectURL := strings.TrimSpace(os.Getenv("GOOGLE_REDIRECT_URL")) + if redirectURL == "" { + redirectURL = "http://localhost:8080/auth/google/callback" + } + if clientID == "" || clientSecret == "" { + return nil, os.ErrInvalid + } + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + }, nil +} + +func generateOAuthState() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func oauthStateCookie(c *gin.Context, value string, maxAge int) *http.Cookie { + secure := false + if redirectURL := strings.TrimSpace(os.Getenv("GOOGLE_REDIRECT_URL")); strings.HasPrefix(redirectURL, "https://") { + secure = true + } + return &http.Cookie{ + Name: "oauth_state", + Value: value, + Path: "/auth/google", + MaxAge: maxAge, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + } +} + +func clearOAuthStateCookie(c *gin.Context) { + http.SetCookie(c.Writer, oauthStateCookie(c, "", -1)) +} + +func getFrontendOrigin() string { + origin := strings.TrimRight(strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN")), "/") + if origin == "" { + return "http://localhost:5173" + } + return origin +} + +func respondOAuthError(c *gin.Context, status int, message string) { + c.Header("Cache-Control", "no-store") + c.JSON(status, gin.H{ + "error": message, + "code": "OAUTH_ERROR", + "details": []string{}, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/backend/daos/mocks/user_dao_mock.go b/backend/daos/mocks/user_dao_mock.go index a426c5f..cde2c26 100644 --- a/backend/daos/mocks/user_dao_mock.go +++ b/backend/daos/mocks/user_dao_mock.go @@ -25,6 +25,15 @@ func (m *MockUserDAO) FindByEmail(email string) (*models.User, error) { return args.Get(0).(*models.User), args.Error(1) } +// FindByGoogleID mocks the FindByGoogleID method. +func (m *MockUserDAO) FindByGoogleID(googleID string) (*models.User, error) { + args := m.Called(googleID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.User), args.Error(1) +} + // FindByUsername mocks the FindByUsername method. func (m *MockUserDAO) FindByUsername(username string) (*models.User, error) { args := m.Called(username) diff --git a/backend/daos/user_dao.go b/backend/daos/user_dao.go index b257e1f..50a90d7 100644 --- a/backend/daos/user_dao.go +++ b/backend/daos/user_dao.go @@ -10,6 +10,7 @@ import ( type UserDAO interface { Create(user *models.User) error FindByEmail(email string) (*models.User, error) + FindByGoogleID(googleID string) (*models.User, error) FindByUsername(username string) (*models.User, error) FindByID(id int64) (*models.User, error) Update(user *models.User) error @@ -36,6 +37,15 @@ func (d *userDAO) FindByEmail(email string) (*models.User, error) { return &user, err } +func (d *userDAO) FindByGoogleID(googleID string) (*models.User, error) { + var user models.User + err := d.db.Where("google_id = ?", googleID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, gorm.ErrRecordNotFound + } + return &user, err +} + func (d *userDAO) FindByUsername(username string) (*models.User, error) { var user models.User err := d.db.Where("username = ?", username).First(&user).Error diff --git a/backend/go.mod b/backend/go.mod index 307f4ee..7f21b6e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,12 @@ go 1.24.11 require github.com/gin-gonic/gin v1.10.1 -require github.com/golang-jwt/jwt/v5 v5.2.2 +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + golang.org/x/oauth2 v0.30.0 +) + +require cloud.google.com/go/compute/metadata v0.3.0 // indirect require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 882a8f5..40cf335 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= @@ -92,6 +94,8 @@ golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/backend/models/user.go b/backend/models/user.go index d4640b2..7034546 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -9,6 +9,7 @@ type User struct { Username string `gorm:"uniqueIndex;not null;size:50;column:username" json:"username"` Email string `gorm:"uniqueIndex;not null;size:255;column:email" json:"email"` Password string `gorm:"not null;type:text;column:password" json:"-"` + GoogleID *string `gorm:"uniqueIndex;size:255;column:google_id" json:"-"` AvatarURL *string `gorm:"size:500;column:avatar_url" json:"avatar_url,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime;column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at" json:"updated_at"` diff --git a/backend/services/auth_service.go b/backend/services/auth_service.go index 6e3110a..911e3b4 100644 --- a/backend/services/auth_service.go +++ b/backend/services/auth_service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "os" + "regexp" "strconv" "strings" "time" @@ -20,6 +21,7 @@ import ( type AuthService interface { Register(username string, email string, password string) (*models.User, error) Login(email string, password string) (*models.User, string, string, int64, int64, error) + LoginWithGoogle(profile GoogleProfile) (*models.User, string, string, int64, int64, error) Refresh(refreshToken string) (string, string, int64, int64, error) Logout(refreshToken string) error FindUserByID(id int64) (*models.User, error) @@ -27,6 +29,16 @@ type AuthService interface { JWTSecret() []byte } +type GoogleProfile struct { + Subject string + Email string + EmailVerified bool + Name string + Picture string +} + +var ErrGoogleLoginRequired = errors.New("this account was created with Google; please sign in with Google") + type authService struct { users daos.UserDAO refreshTokens daos.RefreshTokenDAO @@ -71,6 +83,9 @@ func (s *authService) Register(username string, email string, password string) ( } if existing, _ := s.users.FindByEmail(strings.ToLower(email)); existing != nil { + if existing.Password == "" && existing.GoogleID != nil { + return nil, ErrGoogleLoginRequired + } return nil, errors.New("email already exists") } if existing, _ := s.users.FindByUsername(username); existing != nil { @@ -102,6 +117,65 @@ func (s *authService) Login(email string, password string) (*models.User, string return nil, "", "", 0, 0, errors.New("invalid credentials") } + return s.issueLoginTokens(user) +} + +func (s *authService) LoginWithGoogle(profile GoogleProfile) (*models.User, string, string, int64, int64, error) { + if strings.TrimSpace(profile.Subject) == "" { + return nil, "", "", 0, 0, errors.New("missing Google subject") + } + if !profile.EmailVerified { + return nil, "", "", 0, 0, errors.New("Google email is not verified") + } + email := strings.ToLower(strings.TrimSpace(profile.Email)) + if err := validateEmail(email); err != nil { + return nil, "", "", 0, 0, err + } + + if user, err := s.users.FindByGoogleID(profile.Subject); err == nil && user != nil { + return s.issueLoginTokens(user) + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", "", 0, 0, err + } + + googleID := profile.Subject + user, err := s.users.FindByEmail(email) + if err == nil && user != nil { + user.GoogleID = &googleID + if user.AvatarURL == nil && strings.TrimSpace(profile.Picture) != "" { + picture := strings.TrimSpace(profile.Picture) + if validateAvatarURL(picture) == nil { + user.AvatarURL = &picture + } + } + if err := s.users.Update(user); err != nil { + return nil, "", "", 0, 0, err + } + return s.issueLoginTokens(user) + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", "", 0, 0, err + } + + username, err := s.generateGoogleUsername(email, profile.Name) + if err != nil { + return nil, "", "", 0, 0, err + } + newUser := &models.User{ + Username: username, + Email: email, + Password: "", + GoogleID: &googleID, + } + if picture := strings.TrimSpace(profile.Picture); picture != "" && validateAvatarURL(picture) == nil { + newUser.AvatarURL = &picture + } + if err := s.users.Create(newUser); err != nil { + return nil, "", "", 0, 0, err + } + return s.issueLoginTokens(newUser) +} + +func (s *authService) issueLoginTokens(user *models.User) (*models.User, string, string, int64, int64, error) { accessToken, accessExp, err := s.generateAccessToken(user) if err != nil { return nil, "", "", 0, 0, err @@ -131,6 +205,42 @@ func (s *authService) Login(email string, password string) (*models.User, string return user, accessToken, refreshToken, accessExp - time.Now().UTC().Unix(), accessExp, nil } +func (s *authService) generateGoogleUsername(email string, name string) (string, error) { + baseSource := strings.TrimSpace(name) + if baseSource == "" { + baseSource = strings.Split(email, "@")[0] + } + base := sanitizeUsernameBase(baseSource) + if len(base) < 3 { + base = "user" + base + } + if len(base) > 42 { + base = base[:42] + } + for i := 0; i < 100; i++ { + candidate := base + if i > 0 { + candidate = base + strconv.Itoa(i) + if len(candidate) > 50 { + suffix := strconv.Itoa(i) + candidate = base[:50-len(suffix)] + suffix + } + } + if existing, _ := s.users.FindByUsername(candidate); existing == nil { + return candidate, nil + } + } + token, err := generateTokenID(4) + if err != nil { + return "", err + } + candidate := base + if len(candidate) > 41 { + candidate = candidate[:41] + } + return candidate + "_" + token, nil +} + func (s *authService) Refresh(refreshToken string) (string, string, int64, int64, error) { now := time.Now().UTC() token, err := jwt.Parse(refreshToken, func(t *jwt.Token) (interface{}, error) { @@ -356,6 +466,18 @@ func validateEmail(e string) error { return nil } +var usernameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_]+`) + +func sanitizeUsernameBase(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = usernameSanitizer.ReplaceAllString(s, "_") + s = strings.Trim(s, "_") + if s == "" { + return "user" + } + return s +} + func validateAvatarURL(url string) error { if url == "" { return nil diff --git a/backend/services/auth_service_test.go b/backend/services/auth_service_test.go index f6e6da3..7b0aaaf 100644 --- a/backend/services/auth_service_test.go +++ b/backend/services/auth_service_test.go @@ -161,6 +161,28 @@ func TestRegister_DuplicateEmail(t *testing.T) { userDAO.AssertExpectations(t) } +func TestRegister_GoogleOnlyEmailRequiresGoogleLogin(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + + googleID := "google-subject" + existingUser := &models.User{ + ID: 1, + Email: "test@example.com", + Password: "", + GoogleID: &googleID, + } + userDAO.On("FindByEmail", "test@example.com").Return(existingUser, nil) + + service := NewAuthService(userDAO, refreshDAO) + + _, err := service.Register("testuser", "test@example.com", "password123") + + assert.ErrorIs(t, err, ErrGoogleLoginRequired) + assert.Equal(t, "this account was created with Google; please sign in with Google", err.Error()) + userDAO.AssertExpectations(t) +} + func TestRegister_DuplicateUsername(t *testing.T) { userDAO := &mocks.MockUserDAO{} refreshDAO := &mocks.MockRefreshTokenDAO{} @@ -247,6 +269,141 @@ func TestLogin_InvalidPassword(t *testing.T) { userDAO.AssertExpectations(t) } +func TestLoginWithGoogle_CreatesNewUser(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + + userDAO.On("FindByGoogleID", "google-subject").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("FindByEmail", "new@example.com").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("FindByUsername", "new_user").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("Create", mock.MatchedBy(func(user *models.User) bool { + if user.ID == 0 { + user.ID = 1 + } + return user.Username == "new_user" && + user.Email == "new@example.com" && + user.Password == "" && + user.GoogleID != nil && + *user.GoogleID == "google-subject" + })).Return(nil) + refreshDAO.On("Create", mock.AnythingOfType("*models.RefreshToken")).Return(nil) + + service := NewAuthService(userDAO, refreshDAO) + + user, accessToken, refreshToken, expiresIn, accessExp, err := service.LoginWithGoogle(GoogleProfile{ + Subject: "google-subject", + Email: "new@example.com", + EmailVerified: true, + Name: "New User", + }) + + assert.NoError(t, err) + assert.Equal(t, "new_user", user.Username) + assert.Empty(t, user.Password) + assert.NotEmpty(t, accessToken) + assert.NotEmpty(t, refreshToken) + assert.Greater(t, expiresIn, int64(0)) + assert.Greater(t, accessExp, int64(0)) + userDAO.AssertExpectations(t) + refreshDAO.AssertExpectations(t) +} + +func TestLoginWithGoogle_LinksExistingEmail(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + + existing := &models.User{ID: 1, Username: "existing", Email: "existing@example.com"} + userDAO.On("FindByGoogleID", "google-subject").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("FindByEmail", "existing@example.com").Return(existing, nil) + userDAO.On("Update", mock.MatchedBy(func(user *models.User) bool { + return user.ID == 1 && user.GoogleID != nil && *user.GoogleID == "google-subject" + })).Return(nil) + refreshDAO.On("Create", mock.AnythingOfType("*models.RefreshToken")).Return(nil) + + service := NewAuthService(userDAO, refreshDAO) + + user, _, _, _, _, err := service.LoginWithGoogle(GoogleProfile{ + Subject: "google-subject", + Email: "existing@example.com", + EmailVerified: true, + }) + + assert.NoError(t, err) + assert.Equal(t, "existing", user.Username) + assert.Empty(t, user.Password) + userDAO.AssertExpectations(t) + refreshDAO.AssertExpectations(t) +} + +func TestLoginWithGoogle_RejectsUnverifiedEmail(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + service := NewAuthService(userDAO, refreshDAO) + + _, _, _, _, _, err := service.LoginWithGoogle(GoogleProfile{ + Subject: "google-subject", + Email: "test@example.com", + EmailVerified: false, + }) + + assert.Error(t, err) + assert.Equal(t, "Google email is not verified", err.Error()) +} + +func TestLoginWithGoogle_ReusesGoogleSubject(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + + existing := &models.User{ID: 1, Username: "linked", Email: "linked@example.com"} + userDAO.On("FindByGoogleID", "google-subject").Return(existing, nil) + refreshDAO.On("Create", mock.AnythingOfType("*models.RefreshToken")).Return(nil) + + service := NewAuthService(userDAO, refreshDAO) + + user, _, _, _, _, err := service.LoginWithGoogle(GoogleProfile{ + Subject: "google-subject", + Email: "linked@example.com", + EmailVerified: true, + }) + + assert.NoError(t, err) + assert.Equal(t, "linked", user.Username) + assert.Empty(t, user.Password) + userDAO.AssertExpectations(t) + refreshDAO.AssertExpectations(t) +} + +func TestLoginWithGoogle_UsernameCollision(t *testing.T) { + userDAO := &mocks.MockUserDAO{} + refreshDAO := &mocks.MockRefreshTokenDAO{} + + userDAO.On("FindByGoogleID", "google-subject").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("FindByEmail", "taken@example.com").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("FindByUsername", "taken").Return(&models.User{ID: 1}, nil) + userDAO.On("FindByUsername", "taken1").Return(nil, gorm.ErrRecordNotFound) + userDAO.On("Create", mock.MatchedBy(func(user *models.User) bool { + if user.ID == 0 { + user.ID = 2 + } + return user.Username == "taken1" + })).Return(nil) + refreshDAO.On("Create", mock.AnythingOfType("*models.RefreshToken")).Return(nil) + + service := NewAuthService(userDAO, refreshDAO) + + user, _, _, _, _, err := service.LoginWithGoogle(GoogleProfile{ + Subject: "google-subject", + Email: "taken@example.com", + EmailVerified: true, + Name: "taken", + }) + + assert.NoError(t, err) + assert.Equal(t, "taken1", user.Username) + userDAO.AssertExpectations(t) + refreshDAO.AssertExpectations(t) +} + func TestRefresh_Success(t *testing.T) { userDAO := &mocks.MockUserDAO{} refreshDAO := &mocks.MockRefreshTokenDAO{} diff --git a/docker-compose.yml b/docker-compose.yml index 32909d4..16cc027 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,9 @@ services: JWT_REFRESH_EXPIRE: ${JWT_REFRESH_EXPIRE:-720h} TMDB_API_TOKEN: ${TMDB_API_TOKEN:-} FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-5173}} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL:-http://localhost:${BACKEND_PORT:-8080}/auth/google/callback} ports: - "${BACKEND_PORT:-8080}:8080" volumes: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ebaff86..06d9ac2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import ProtectedRoute from './components/ProtectedRoute' import LandingPage from './pages/LandingPage' import LoginPage from './pages/Login' import RegistroPage from './pages/Registro' +import GoogleOAuthCallbackPage from './pages/GoogleOAuthCallback' import HomePage from './pages/Home' import ListPage from './pages/List' import SettingsPage from './pages/Settings' @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/assets/google-logo.svg b/frontend/src/assets/google-logo.svg new file mode 100644 index 0000000..6d44c2e --- /dev/null +++ b/frontend/src/assets/google-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index fa34353..60ee8dc 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -108,6 +108,12 @@ "username": "Username", "signIn": "Sign in", "signingIn": "Signing in…", + "continueWithGoogle": "Continue with Google", + "or": "Or", + "finishingGoogleLogin": "Finishing Google sign-in…", + "googleCallbackFailed": "Google sign-in failed", + "googleCallbackError": "Could not complete Google sign-in.", + "googleLoginRequired": "This account was created with Google. Sign in using Continue with Google.", "register": "Create account", "creating": "Creating…", "registerSuccess": "Registration complete. You can now log in.", @@ -208,5 +214,3 @@ "watchProvidersAttribution": "Data provided by JustWatch" } } - - diff --git a/frontend/src/locales/pt.json b/frontend/src/locales/pt.json index b9a2be3..2e2650a 100644 --- a/frontend/src/locales/pt.json +++ b/frontend/src/locales/pt.json @@ -108,6 +108,12 @@ "username": "Usuário", "signIn": "Entrar", "signingIn": "Entrando…", + "continueWithGoogle": "Continuar com Google", + "or": "Ou", + "finishingGoogleLogin": "Finalizando login com Google…", + "googleCallbackFailed": "Falha no login com Google", + "googleCallbackError": "Não foi possível concluir o login com Google.", + "googleLoginRequired": "Essa conta foi criada com Google. Entre usando o botão Continuar com Google.", "register": "Criar conta", "creating": "Criando…", "registerSuccess": "Registro concluído. Você já pode fazer login.", @@ -208,5 +214,3 @@ "watchProvidersAttribution": "Dados fornecidos por JustWatch" } } - - diff --git a/frontend/src/pages/GoogleOAuthCallback.tsx b/frontend/src/pages/GoogleOAuthCallback.tsx new file mode 100644 index 0000000..bb05b4e --- /dev/null +++ b/frontend/src/pages/GoogleOAuthCallback.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { setStoredAuth } from '@/services/auth_storage' +import type { UserDTO } from '@/services/auth' + +export default function GoogleOAuthCallbackPage() { + const navigate = useNavigate() + const { t } = useTranslation() + const [error, setError] = useState(null) + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.replace(/^#/, '')) + const oauthError = params.get('error') + if (oauthError) { + window.history.replaceState(null, '', window.location.pathname) + setError(t('auth.googleCallbackError')) + return + } + + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const userRaw = params.get('user') + if (!accessToken || !refreshToken || !userRaw) { + window.history.replaceState(null, '', window.location.pathname) + setError(t('auth.googleCallbackError')) + return + } + + try { + const user = JSON.parse(userRaw) as UserDTO + setStoredAuth(accessToken, refreshToken, user) + window.history.replaceState(null, '', window.location.pathname) + + const pendingCode = sessionStorage.getItem('pending_invite_code') + if (pendingCode) { + sessionStorage.removeItem('pending_invite_code') + navigate(`/join/${pendingCode}`, { replace: true }) + } else { + navigate('/home', { replace: true }) + } + } catch { + window.history.replaceState(null, '', window.location.pathname) + setError(t('auth.googleCallbackError')) + } + }, [navigate, t]) + + return ( +
+
+ {error ? ( + <> +

{t('auth.googleCallbackFailed')}

+

{error}

+ + {t('auth.signInLink')} + + + ) : ( +

{t('auth.finishingGoogleLogin')}

+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index b8c2ebd..b3444ff 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { ArrowLeft } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { login } from '@/services/auth' +import { login, startGoogleLogin } from '@/services/auth' import { setStoredAuth } from '@/services/auth_storage' import postersImg from '@/assets/poster_background.png' +import googleLogo from '@/assets/google-logo.svg' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import type { ApiException } from '@/services/api' @@ -63,7 +64,22 @@ export default function LoginPage() {
{error}
)} -
+ + +
+
+ {t('auth.or')} +
+
+ +
)} - + + +
+
+ {t('auth.or')} +
+
+ +