Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ JWT_SECRET=my_super_long_and_secure_secret_key
# Cryptography Parameters
ARGON_MEMORY=65536
ARGON_ITERATIONS=3
ARGON_PARALLELISM=2
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
33 changes: 33 additions & 0 deletions server/config/oauth.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
128 changes: 96 additions & 32 deletions server/controllers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,35 +21,26 @@ 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
}

svc := services.NewUserService()
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
}

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

Expand All @@ -73,7 +64,6 @@ func HandleRegister(w http.ResponseWriter, r *http.Request) {
})
}


type LoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
Expand Down Expand Up @@ -116,35 +106,109 @@ 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
}

svc := services.NewAuthService()
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,
},
})
}
}
2 changes: 2 additions & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ func main() {
config.InitDatabase()
defer config.DB.Close()

// OAuth init
config.InitOAuth()

// WS init
go services.Hub.Run()

Expand Down
7 changes: 7 additions & 0 deletions server/migrations/009_add_oauth_providers_up.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions server/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading
Loading