diff --git a/.env.example b/.env.example index 7444f7a..3daa2aa 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,15 @@ JWT_SECRET=my_super_long_and_secure_secret_key # Cryptography Parameters ARGON_MEMORY=65536 ARGON_ITERATIONS=3 -ARGON_PARALLELISM=2 \ No newline at end of file +ARGON_PARALLELISM=2 + +# OAuth2 Authentication Providers +# Google Developer Console Credentials +GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-your_google_client_secret +GOOGLE_REDIRECT_URL=http://localhost:3000/api/auth/google/callback + +# GitHub Developer Settings Credentials +GITHUB_CLIENT_ID=ov-your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_REDIRECT_URL=http://localhost:3000/api/auth/github/callback \ No newline at end of file diff --git a/server/config/oauth.go b/server/config/oauth.go new file mode 100644 index 0000000..c1abd0c --- /dev/null +++ b/server/config/oauth.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" +) + +var GoogleConfig *oauth2.Config +var GithubConfig *oauth2.Config + +func InitOAuth() { + GoogleConfig = &oauth2.Config{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + RedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"), + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + }, + Endpoint: google.Endpoint, + } + + GithubConfig = &oauth2.Config{ + ClientID: os.Getenv("GITHUB_CLIENT_ID"), + ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"), + Scopes: []string{"user:email", "read:user"}, + Endpoint: github.Endpoint, + } +} \ No newline at end of file diff --git a/server/controllers/auth.go b/server/controllers/auth.go index 93a1441..d42714f 100644 --- a/server/controllers/auth.go +++ b/server/controllers/auth.go @@ -3,11 +3,14 @@ package controllers import ( "encoding/json" "net/http" + "strings" "time" + "github.com/commandlinecoding/elephant/server/config" "github.com/commandlinecoding/elephant/server/models" "github.com/commandlinecoding/elephant/server/repository" "github.com/commandlinecoding/elephant/server/services" + "github.com/go-chi/chi/v5" ) type RegisterReq struct { @@ -18,14 +21,11 @@ type RegisterReq struct { func HandleRegister(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - + var req RegisterReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: "Invalid payload syntax", - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid payload syntax"}) return } @@ -33,20 +33,14 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) { user, err := svc.Register(r.Context(), req.Username, req.DisplayName, req.Password) if err != nil { w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: err.Error(), - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()}) return } tokens, err := services.GenerateTokenPair(user.ID) if err != nil { w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: "Token generation failure", - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Token generation failure"}) return } @@ -56,10 +50,7 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) { if err := tokenRepo.StoreToken(r.Context(), user.ID, hashedRt, expiry); err != nil { w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: "Failed to securely record registration token metrics", - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to securely record registration token metrics"}) return } @@ -73,7 +64,6 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) { }) } - type LoginReq struct { Username string `json:"username"` Password string `json:"password"` @@ -116,19 +106,13 @@ func HandleRefresh(w http.ResponseWriter, r *http.Request) { var req RefreshReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: "Invalid json payload structure", - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid json payload structure"}) return } if req.RefreshToken == "" { w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: "Refresh token parameter missing", - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Refresh token parameter missing"}) return } @@ -136,15 +120,95 @@ func HandleRefresh(w http.ResponseWriter, r *http.Request) { tokens, err := svc.Refresh(r.Context(), req.RefreshToken) if err != nil { w.WriteHeader(http.StatusUnauthorized) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: false, - Error: err.Error(), - }) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: tokens}) +} + +func HandleOAuthRedirect(w http.ResponseWriter, r *http.Request) { + provider := chi.URLParam(r, "provider") + state := services.GenerateStateToken() + + cookie := &http.Cookie{ + Name: "oauth_state", + Value: state, + Path: "/", + HttpOnly: true, + Secure: false, + MaxAge: 300, + } + http.SetCookie(w, cookie) + + var url string + switch provider { + case "google": + url = config.GoogleConfig.AuthCodeURL(state) + case "github": + url = config.GithubConfig.AuthCodeURL(state) + default: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Unsupported OAuth provider context"}) + return + } + + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + provider := chi.URLParam(r, "provider") + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + cookie, err := r.Cookie("oauth_state") + if err != nil || cookie.Value != state { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "State token match validation failed"}) + return + } + + profile, err := services.ProcessCallback(r.Context(), provider, code) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()}) return } + userRepo := repository.NewUserRepository() + u, err := userRepo.FindByProviderID(r.Context(), provider, profile.ID) + if err != nil { + u, err = userRepo.FindByEmail(r.Context(), profile.Email) + if err == nil { + _ = userRepo.LinkProviderAccount(r.Context(), u.ID, provider, profile.ID) + } else { + fallbackUsername := strings.Split(profile.Email, "@")[0] + u, err = userRepo.CreateOAuthUser(r.Context(), fallbackUsername, profile.Email, profile.Name, provider, profile.ID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to allocate user credentials"}) + return + } + } + } + + tokens, err := services.GenerateTokenPair(u.ID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + tokenRepo := repository.NewRefreshTokenRepository() + hashedRt := services.HashToken(tokens.RefreshToken) + expiry := time.Now().Add(7 * 24 * time.Hour) + _ = tokenRepo.StoreToken(r.Context(), u.ID, hashedRt, expiry) + _ = json.NewEncoder(w).Encode(models.JSONResponse{ Success: true, - Data: tokens, + Data: map[string]interface{}{ + "user": u, + "tokens": tokens, + }, }) -} \ No newline at end of file +} diff --git a/server/go.mod b/server/go.mod index 293c037..5532060 100644 --- a/server/go.mod +++ b/server/go.mod @@ -9,9 +9,11 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.53.0 + golang.org/x/oauth2 v0.36.0 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/server/go.sum b/server/go.sum index 3856069..119134e 100644 --- a/server/go.sum +++ b/server/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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -26,6 +28,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/server/main.go b/server/main.go index 7b8bfdd..32455d8 100644 --- a/server/main.go +++ b/server/main.go @@ -12,6 +12,9 @@ func main() { config.InitDatabase() defer config.DB.Close() + // OAuth init + config.InitOAuth() + // WS init go services.Hub.Run() diff --git a/server/migrations/009_add_oauth_providers_up.sql b/server/migrations/009_add_oauth_providers_up.sql new file mode 100644 index 0000000..b012779 --- /dev/null +++ b/server/migrations/009_add_oauth_providers_up.sql @@ -0,0 +1,7 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) UNIQUE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS google_id VARCHAR(255) UNIQUE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS github_id VARCHAR(255) UNIQUE; + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id) WHERE google_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id) WHERE github_id IS NOT NULL; \ No newline at end of file diff --git a/server/models/user.go b/server/models/user.go index 0dbe003..3fd5be4 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -5,7 +5,10 @@ import "time" type User struct { ID string `json:"id"` Username string `json:"username"` + Email string `json:"email,omitempty"` DisplayName string `json:"display_name"` PasswordHash string `json:"-"` + GoogleID string `json:"google_id,omitempty"` + GithubID string `json:"github_id,omitempty"` CreatedAt time.Time `json:"created_at"` } \ No newline at end of file diff --git a/server/repository/user.go b/server/repository/user.go index 4dbab1c..33c9586 100644 --- a/server/repository/user.go +++ b/server/repository/user.go @@ -21,9 +21,7 @@ func (r *UserRepository) CreateUser(ctx context.Context, username, displayName, VALUES ($1, $2, $3, NOW()) RETURNING id, username, display_name, password_hash, created_at; ` - var user models.User - err := config.DB.QueryRow(ctx, query, username, displayName, passwordHash).Scan( &user.ID, &user.Username, @@ -34,19 +32,16 @@ func (r *UserRepository) CreateUser(ctx context.Context, username, displayName, if err != nil { return nil, err } - return &user, nil } func (r *UserRepository) UsernameExists(ctx context.Context, username string) (bool, error) { query := `SELECT EXISTS(SELECT 1 FROM users WHERE username = $1);` - var exists bool err := config.DB.QueryRow(ctx, query, username).Scan(&exists) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return false, err } - return exists, nil } @@ -72,7 +67,6 @@ func (r *UserRepository) FindByUsername(ctx context.Context, username string) (* func (r *UserRepository) SearchUsers(ctx context.Context, searchTerm string, limit, offset int) ([]models.User, error) { pattern := "%" + searchTerm + "%" - query := ` SELECT id, username, display_name, created_at FROM users @@ -80,7 +74,6 @@ func (r *UserRepository) SearchUsers(ctx context.Context, searchTerm string, lim ORDER BY username ASC LIMIT $3 OFFSET $4; ` - rows, err := config.DB.Query(ctx, query, pattern, pattern, limit, offset) if err != nil { return nil, err @@ -96,11 +89,9 @@ func (r *UserRepository) SearchUsers(ctx context.Context, searchTerm string, lim } users = append(users, u) } - if err = rows.Err(); err != nil { return nil, err } - return users, nil } @@ -121,4 +112,67 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*models.User, return nil, err } return &user, nil -} \ No newline at end of file +} + +func (r *UserRepository) FindByProviderID(ctx context.Context, provider, providerID string) (*models.User, error) { + column := "google_id" + if provider == "github" { + column = "github_id" + } + + query := ` + SELECT id, username, COALESCE(email, ''), display_name, COALESCE(google_id, ''), COALESCE(github_id, ''), created_at + FROM users + WHERE ` + column + ` = $1; + ` + var u models.User + err := config.DB.QueryRow(ctx, query, providerID).Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, &u.GoogleID, &u.GithubID, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) { + query := ` + SELECT id, username, COALESCE(email, ''), display_name, COALESCE(google_id, ''), COALESCE(github_id, ''), created_at + FROM users + WHERE email = $1; + ` + var u models.User + err := config.DB.QueryRow(ctx, query, email).Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, &u.GoogleID, &u.GithubID, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *UserRepository) LinkProviderAccount(ctx context.Context, userID, provider, providerID string) error { + column := "google_id" + if provider == "github" { + column = "github_id" + } + + query := `UPDATE users SET ` + column + ` = $1 WHERE id = $2::uuid;` + _, err := config.DB.Exec(ctx, query, providerID, userID) + return err +} + +func (r *UserRepository) CreateOAuthUser(ctx context.Context, username, email, displayName, provider, providerID string) (*models.User, error) { + column := "google_id" + if provider == "github" { + column = "github_id" + } + + query := ` + INSERT INTO users (username, email, display_name, password_hash, ` + column + `, created_at) + VALUES ($1, $2, $3, 'OAUTH_PASSWORDLESS', $4, NOW()) + RETURNING id, username, COALESCE(email, ''), display_name, created_at; + ` + var u models.User + err := config.DB.QueryRow(ctx, query, username, email, displayName, providerID).Scan(&u.ID, &u.Username, &u.Email, &u.DisplayName, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} diff --git a/server/routes/auth.go b/server/routes/auth.go index 52e9b05..86728b5 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -9,4 +9,7 @@ func AuthRoute(router chi.Router) { router.Post("/register", controllers.HandleRegister) router.Post("/login", controllers.HandleLogin) router.Post("/refresh", controllers.HandleRefresh) + + router.Get("/{provider}", controllers.HandleOAuthRedirect) + router.Get("/{provider}/callback", controllers.HandleOAuthCallback) } \ No newline at end of file diff --git a/server/services/oauth.go b/server/services/oauth.go new file mode 100644 index 0000000..640a7fb --- /dev/null +++ b/server/services/oauth.go @@ -0,0 +1,107 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/commandlinecoding/elephant/server/config" +) + +type OAuthProfile struct { + ID string + Email string + Name string +} + +func GenerateStateToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} + +func ProcessCallback(ctx context.Context, provider, code string) (*OAuthProfile, error) { + switch provider { + case "google": + return fetchGoogleUser(ctx, code) + case "github": + return fetchGithubUser(ctx, code) + default: + return nil, fmt.Errorf("unsupported provider context") + } +} + +func fetchGoogleUser(ctx context.Context, code string) (*OAuthProfile, error) { + tok, err := config.GoogleConfig.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("google token exchange failed: %w", err) + } + + client := config.GoogleConfig.Client(ctx, tok) + resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + return nil, fmt.Errorf("failed fetching google user data: %w", err) + } + defer resp.Body.Close() + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return &OAuthProfile{ + ID: fmt.Sprintf("%v", data["id"]), + Email: fmt.Sprintf("%v", data["email"]), + Name: fmt.Sprintf("%v", data["name"]), + }, nil +} + +func fetchGithubUser(ctx context.Context, code string) (*OAuthProfile, error) { + tok, err := config.GithubConfig.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("github token exchange failed: %w", err) + } + + client := config.GithubConfig.Client(ctx, tok) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + return nil, fmt.Errorf("failed fetching github user data: %w", err) + } + defer resp.Body.Close() + + var userRaw map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&userRaw); err != nil { + return nil, err + } + + profile := &OAuthProfile{ + ID: fmt.Sprintf("%v", userRaw["id"]), + Name: fmt.Sprintf("%v", userRaw["name"]), + } + + if emailOpt, ok := userRaw["email"].(string); ok && emailOpt != "" { + profile.Email = emailOpt + } else { + emailResp, err := client.Get("https://api.github.com/user/emails") + if err == nil { + defer emailResp.Body.Close() + var emails []map[string]interface{} + if err := json.NewDecoder(emailResp.Body).Decode(&emails); err == nil { + for _, e := range emails { + if primary, _ := e["primary"].(bool); primary { + profile.Email = fmt.Sprintf("%v", e["email"]) + break + } + } + } + } + } + + if profile.Email == "" { + return nil, fmt.Errorf("unable to resolve verified primary email address context from github account") + } + + return profile, nil +}