google auth integration

This commit is contained in:
Yared Yemane 2026-01-21 12:43:02 -08:00
parent 9ee1d7f714
commit 68472b09b1
13 changed files with 564 additions and 6 deletions

View File

@ -11,7 +11,7 @@ INSERT INTO users (
phone_number,
role,
password,
age,
age_group,
education_level,
country,
region,
@ -44,7 +44,7 @@ VALUES
NULL,
'USER',
crypt('password@123', gen_salt('bf'))::bytea,
22,
'25_34',
'Bachelor',
'Ethiopia',
'Addis Ababa',
@ -76,7 +76,7 @@ VALUES
'0911001100',
'ADMIN',
crypt('password@123', gen_salt('bf'))::bytea,
28,
'35_44',
'Master',
'Ethiopia',
'Addis Ababa',
@ -108,7 +108,7 @@ VALUES
'0911223344',
'SUPPORT',
crypt('password@123', gen_salt('bf'))::bytea,
25,
'55_PLUS',
'Diploma',
'Ethiopia',
'Addis Ababa',

View File

@ -63,6 +63,17 @@
)
);
ALTER TABLE users
ADD COLUMN google_id VARCHAR(255) UNIQUE,
ADD COLUMN google_email_verified BOOLEAN DEFAULT FALSE;
ALTER TABLE users
ALTER COLUMN password DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_id
ON users (google_id)
WHERE google_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS assessment_questions (
id BIGSERIAL PRIMARY KEY,

View File

@ -1,3 +1,29 @@
-- name: CreateGoogleUser :one
INSERT INTO users (
first_name,
last_name,
email,
google_id,
google_email_verified,
role,
status,
email_verified,
profile_picture_url,
profile_completed
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, true, $8, false
)
RETURNING *;
-- name: LinkGoogleAccount :exec
UPDATE users
SET
google_id = $2,
google_email_verified = true,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: IsUserPending :one
SELECT
CASE WHEN status = 'PENDING' THEN true ELSE false END AS is_pending
@ -120,6 +146,11 @@ SELECT *
FROM users
WHERE id = $1;
-- name: GetUserByGoogleID :one
SELECT *
FROM users
WHERE google_id = $1;
-- name: GetAllUsers :many
SELECT

View File

@ -242,4 +242,6 @@ type User struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
AgeGroup pgtype.Text `json:"age_group"`
GoogleID pgtype.Text `json:"google_id"`
GoogleEmailVerified pgtype.Bool `json:"google_email_verified"`
}

View File

@ -38,6 +38,85 @@ func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailE
return i, err
}
const CreateGoogleUser = `-- name: CreateGoogleUser :one
INSERT INTO users (
first_name,
last_name,
email,
google_id,
google_email_verified,
role,
status,
email_verified,
profile_picture_url,
profile_completed
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, true, $8, false
)
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified
`
type CreateGoogleUserParams struct {
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
Email pgtype.Text `json:"email"`
GoogleID pgtype.Text `json:"google_id"`
GoogleEmailVerified pgtype.Bool `json:"google_email_verified"`
Role string `json:"role"`
Status string `json:"status"`
ProfilePictureUrl pgtype.Text `json:"profile_picture_url"`
}
func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserParams) (User, error) {
row := q.db.QueryRow(ctx, CreateGoogleUser,
arg.FirstName,
arg.LastName,
arg.Email,
arg.GoogleID,
arg.GoogleEmailVerified,
arg.Role,
arg.Status,
arg.ProfilePictureUrl,
)
var i User
err := row.Scan(
&i.ID,
&i.FirstName,
&i.LastName,
&i.Gender,
&i.BirthDay,
&i.Email,
&i.PhoneNumber,
&i.Role,
&i.Password,
&i.EducationLevel,
&i.Country,
&i.Region,
&i.KnowledgeLevel,
&i.NickName,
&i.Occupation,
&i.LearningGoal,
&i.LanguageGoal,
&i.LanguageChallange,
&i.FavouriteTopic,
&i.InitialAssessmentCompleted,
&i.EmailVerified,
&i.PhoneVerified,
&i.Status,
&i.LastLogin,
&i.ProfileCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.CreatedAt,
&i.UpdatedAt,
&i.AgeGroup,
&i.GoogleID,
&i.GoogleEmailVerified,
)
return i, err
}
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (
first_name,
@ -545,8 +624,54 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
return i, err
}
const GetUserByGoogleID = `-- name: GetUserByGoogleID :one
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified
FROM users
WHERE google_id = $1
`
func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) (User, error) {
row := q.db.QueryRow(ctx, GetUserByGoogleID, googleID)
var i User
err := row.Scan(
&i.ID,
&i.FirstName,
&i.LastName,
&i.Gender,
&i.BirthDay,
&i.Email,
&i.PhoneNumber,
&i.Role,
&i.Password,
&i.EducationLevel,
&i.Country,
&i.Region,
&i.KnowledgeLevel,
&i.NickName,
&i.Occupation,
&i.LearningGoal,
&i.LanguageGoal,
&i.LanguageChallange,
&i.FavouriteTopic,
&i.InitialAssessmentCompleted,
&i.EmailVerified,
&i.PhoneVerified,
&i.Status,
&i.LastLogin,
&i.ProfileCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.CreatedAt,
&i.UpdatedAt,
&i.AgeGroup,
&i.GoogleID,
&i.GoogleEmailVerified,
)
return i, err
}
const GetUserByID = `-- name: GetUserByID :one
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified
FROM users
WHERE id = $1
`
@ -585,6 +710,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.AgeGroup,
&i.GoogleID,
&i.GoogleEmailVerified,
)
return i, err
}
@ -633,6 +760,25 @@ func (q *Queries) IsUserPending(ctx context.Context, id int64) (bool, error) {
return is_pending, err
}
const LinkGoogleAccount = `-- name: LinkGoogleAccount :exec
UPDATE users
SET
google_id = $2,
google_email_verified = true,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
type LinkGoogleAccountParams struct {
ID int64 `json:"id"`
GoogleID pgtype.Text `json:"google_id"`
}
func (q *Queries) LinkGoogleAccount(ctx context.Context, arg LinkGoogleAccountParams) error {
_, err := q.db.Exec(ctx, LinkGoogleAccount, arg.ID, arg.GoogleID)
return err
}
const SearchUserByNameOrPhone = `-- name: SearchUserByNameOrPhone :many
SELECT
id,

6
go.mod
View File

@ -15,7 +15,11 @@ require (
golang.org/x/crypto v0.45.0
)
require github.com/rogpeppe/go-internal v1.8.1 // indirect
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect

4
go.sum
View File

@ -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=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
@ -201,6 +203,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -4,6 +4,15 @@ import (
"time"
)
type GoogleUser struct {
ID string
Email string
VerifiedEmail bool
GivenName string
FamilyName string
Picture string
}
type LoginSuccess struct {
UserId int64
Role Role

View File

@ -8,6 +8,8 @@ import (
)
type UserStore interface {
CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, error)
LinkGoogleAccount(ctx context.Context, userID int64, googleID string) error
IsProfileCompleted(ctx context.Context, userId int64) (bool, error)
UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error
// GetCorrectOptionForQuestion(
@ -34,6 +36,10 @@ type UserStore interface {
ctx context.Context,
user domain.User,
) (domain.User, error)
GetUserByGoogleID(
ctx context.Context,
googleId string,
) (domain.User, error)
GetUserByID(
ctx context.Context,
id int64,

View File

@ -16,6 +16,43 @@ import (
func NewUserStore(s *Store) ports.UserStore { return s }
func (s *Store) LinkGoogleAccount(
ctx context.Context,
userID int64,
googleID string,
) error {
return s.queries.LinkGoogleAccount(ctx, dbgen.LinkGoogleAccountParams{
ID: userID,
GoogleID: pgtype.Text{String: googleID, Valid: true},
})
}
func (s *Store) CreateGoogleUser(
ctx context.Context,
gUser domain.GoogleUser,
) (domain.User, error) {
res, err := s.queries.CreateGoogleUser(ctx, dbgen.CreateGoogleUserParams{
FirstName: pgtype.Text{String: gUser.GivenName, Valid: true},
LastName: pgtype.Text{String: gUser.FamilyName, Valid: true},
Email: pgtype.Text{String: gUser.Email, Valid: true},
GoogleID: pgtype.Text{String: gUser.ID, Valid: true},
GoogleEmailVerified: pgtype.Bool{Bool: gUser.VerifiedEmail, Valid: true},
Role: string(domain.RoleStudent),
Status: string(domain.UserStatusActive),
ProfilePictureUrl: pgtype.Text{
String: gUser.Picture,
Valid: gUser.Picture != "",
},
})
if err != nil {
return domain.User{}, err
}
return mapDBUser(res, nil, nil), nil
}
func (s *Store) IsProfileCompleted(ctx context.Context, userId int64) (bool, error) {
IsProfileCompleted, err := s.queries.IsProfileCompleted(ctx, userId)
if err != nil {
@ -280,6 +317,66 @@ func (s *Store) GetUserByID(
}, nil
}
func (s *Store) GetUserByGoogleID(
ctx context.Context,
googleId string,
) (domain.User, error) {
u, err := s.queries.GetUserByGoogleID(ctx, pgtype.Text{String: googleId, Valid: googleId != ""})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, err
}
var lastLogin *time.Time
if u.LastLogin.Valid {
lastLogin = &u.LastLogin.Time
}
var updatedAt *time.Time
if u.UpdatedAt.Valid {
updatedAt = &u.UpdatedAt.Time
}
return domain.User{
ID: u.ID,
FirstName: u.FirstName.String,
LastName: u.LastName.String,
Gender: u.Gender.String,
BirthDay: u.BirthDay.Time,
// UserName: u.UserName,
Email: u.Email.String,
PhoneNumber: u.PhoneNumber.String,
Role: domain.Role(u.Role),
AgeGroup: u.AgeGroup.String,
EducationLevel: u.EducationLevel.String,
Country: u.Country.String,
Region: u.Region.String,
NickName: u.NickName.String,
Occupation: u.Occupation.String,
LearningGoal: u.LearningGoal.String,
LanguageGoal: u.LanguageGoal.String,
LanguageChallange: u.LanguageChallange.String,
FavouriteTopic: u.FavouriteTopic.String,
EmailVerified: u.EmailVerified,
PhoneVerified: u.PhoneVerified,
Status: domain.UserStatus(u.Status),
LastLogin: lastLogin,
ProfileCompleted: u.ProfileCompleted.Bool,
ProfilePictureURL: u.ProfilePictureUrl.String,
PreferredLanguage: u.PreferredLanguage.String,
CreatedAt: u.CreatedAt.Time,
UpdatedAt: updatedAt,
}, nil
}
// GetAllUsers retrieves users with optional filters
func (s *Store) GetAllUsers(
ctx context.Context,
@ -736,3 +833,46 @@ func mapCreateUserResult(
UpdatedAt: updatedAt,
}
}
// mapDBUser converts dbgen.User to domain.User (used by CreateGoogleUser)
func mapDBUser(
userRes dbgen.User,
password []byte,
updatedAt *time.Time,
) domain.User {
return domain.User{
ID: userRes.ID,
FirstName: userRes.FirstName.String,
LastName: userRes.LastName.String,
Gender: userRes.Gender.String,
BirthDay: userRes.BirthDay.Time,
// UserName: userRes.UserName,
Email: userRes.Email.String,
PhoneNumber: userRes.PhoneNumber.String,
Role: domain.Role(userRes.Role),
Password: password,
AgeGroup: userRes.AgeGroup.String,
EducationLevel: userRes.EducationLevel.String,
Country: userRes.Country.String,
Region: userRes.Region.String,
NickName: userRes.NickName.String,
Occupation: userRes.Occupation.String,
LearningGoal: userRes.LearningGoal.String,
LanguageGoal: userRes.LanguageGoal.String,
LanguageChallange: userRes.LanguageChallange.String,
FavouriteTopic: userRes.FavouriteTopic.String,
EmailVerified: userRes.EmailVerified,
PhoneVerified: userRes.PhoneVerified,
Status: domain.UserStatus(userRes.Status),
ProfileCompleted: userRes.ProfileCompleted.Bool,
PreferredLanguage: userRes.PreferredLanguage.String,
CreatedAt: userRes.CreatedAt.Time,
UpdatedAt: updatedAt,
}
}

View File

@ -0,0 +1,137 @@
package authentication
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"errors"
"fmt"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var googleOAuthConfig *oauth2.Config
func (s *Service) InitGoogleOAuth(clientID, clientSecret, redirectURL string) {
googleOAuthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
}
func (s *Service) GenerateGoogleLoginURL(state string) string {
return googleOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
func (s *Service) ExchangeGoogleCode(
ctx context.Context,
code string,
) (*oauth2.Token, error) {
if code == "" {
return nil, errors.New("missing google auth code")
}
return googleOAuthConfig.Exchange(ctx, code)
}
func (s *Service) FetchGoogleUser(
ctx context.Context,
token *oauth2.Token,
) (*domain.GoogleUser, error) {
client := googleOAuthConfig.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("failed to fetch google user: %w", err)
}
defer resp.Body.Close()
var user domain.GoogleUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode google user: %w", err)
}
if user.Email == "" {
return nil, errors.New("google account has no email")
}
return &user, nil
}
func (s *Service) LoginWithGoogle(
ctx context.Context,
gUser domain.GoogleUser,
) (domain.LoginSuccess, error) {
// 1. Try Google ID login
user, err := s.userStore.GetUserByGoogleID(ctx, gUser.ID)
if err != nil {
// 2. Try account linking by email
user, err = s.userStore.GetUserByEmailPhone(ctx, gUser.Email, "")
if err != nil {
// 3. Create new user
user, err = s.userStore.CreateGoogleUser(ctx, gUser)
if err != nil {
return domain.LoginSuccess{}, err
}
} else {
// Link Google account
if err := s.userStore.LinkGoogleAccount(ctx, user.ID, gUser.ID); err != nil {
return domain.LoginSuccess{}, err
}
}
}
// 4. Status checks (identical to Login)
if user.Status == domain.UserStatusPending {
return domain.LoginSuccess{}, domain.ErrUserNotVerified
}
if user.Status == domain.UserStatusSuspended {
return domain.LoginSuccess{}, ErrUserSuspended
}
// 5. Revoke existing refresh token
oldToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
if err != nil && !errors.Is(err, ErrRefreshTokenNotFound) {
return domain.LoginSuccess{}, err
}
if err == nil && !oldToken.Revoked {
if err := s.tokenStore.RevokeRefreshToken(ctx, oldToken.Token); err != nil {
return domain.LoginSuccess{}, err
}
}
// 6. Generate new refresh token
refreshToken, err := generateRefreshToken()
if err != nil {
return domain.LoginSuccess{}, err
}
if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{
Token: refreshToken,
UserID: user.ID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second),
}); err != nil {
return domain.LoginSuccess{}, err
}
// 7. Return standard response
return domain.LoginSuccess{
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
}, nil
}

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
@ -31,6 +32,71 @@ type loginUserRes struct {
UserID int64 `json:"user_id"`
}
// GoogleLogin godoc
// @Summary Google login redirect
// @Tags auth
// @Router /api/v1/auth/google/login [get]
func (h *Handler) GoogleLogin(c *fiber.Ctx) error {
state := uuid.NewString()
return c.Redirect(h.authSvc.GenerateGoogleLoginURL(state))
}
// GoogleCallback godoc
// @Summary Google login callback
// @Tags auth
// @Router /api/v1/auth/google/callback [get]
func (h *Handler) GoogleCallback(c *fiber.Ctx) error {
code := c.Query("code")
token, err := h.authSvc.ExchangeGoogleCode(c.Context(), code)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Google authentication failed",
Error: err.Error(),
})
}
gUser, err := h.authSvc.FetchGoogleUser(c.Context(), token)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to fetch Google user",
Error: err.Error(),
})
}
loginRes, err := h.authSvc.LoginWithGoogle(c.Context(), *gUser)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Login failed",
Error: err.Error(),
})
}
accessToken, err := jwtutil.CreateJwt(
loginRes.UserId,
loginRes.Role,
h.jwtConfig.JwtAccessKey,
h.jwtConfig.JwtAccessExpiry,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Token generation failed",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Login successful",
Data: fiber.Map{
"accessToken": accessToken,
"refreshToken": loginRes.RfToken,
"userId": loginRes.UserId,
"role": loginRes.Role,
},
})
}
// Loginuser godoc
// @Summary Login user
// @Description Login user

View File

@ -152,6 +152,8 @@ func (a *App) initAppRoutes() {
groupV1.Post("/levels", h.CreateLevel)
// Auth Routes
groupV1.Get("/auth/google/login", h.GoogleLogin)
groupV1.Get("/auth/google/callback", h.GoogleCallback)
groupV1.Post("/auth/customer-login", h.LoginUser)
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper)