mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
522 lines
15 KiB
Go
522 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/trackeep/backend/config"
|
|
"github.com/trackeep/backend/models"
|
|
"github.com/trackeep/backend/utils"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
controlServiceFrontendRedirectCookieName = "control_auth_frontend_redirect"
|
|
controlServiceSessionTokenHeader = "X-Trackeep-Controller-Token"
|
|
controlServiceRequestTimeout = 30 * time.Second
|
|
)
|
|
|
|
var controlServiceBaseURL = config.ControlServiceURL
|
|
|
|
type controlServiceTokenValidationResponse struct {
|
|
Token string `json:"token"`
|
|
User centralizedOAuthUser `json:"user"`
|
|
}
|
|
|
|
type controlServiceErrorResponse struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type controlServiceGitHubAppInfo struct {
|
|
AppSlug string `json:"app_slug"`
|
|
InstallEnabled bool `json:"install_enabled"`
|
|
SignInConfigured bool `json:"sign_in_configured"`
|
|
CredentialsConfigured bool `json:"credentials_configured"`
|
|
}
|
|
|
|
type controlServiceInstallationVerification struct {
|
|
Verified bool `json:"verified"`
|
|
InstallationID int64 `json:"installation_id"`
|
|
AccountLogin string `json:"account_login"`
|
|
AccountType string `json:"account_type"`
|
|
AppSlug string `json:"app_slug"`
|
|
}
|
|
|
|
type controlServiceAccessTokenPayload struct {
|
|
AccessToken string `json:"access_token"`
|
|
Source string `json:"source"`
|
|
InstallationID int64 `json:"installation_id,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
func storeControlServiceAuthFlowState(c *gin.Context, frontendRedirect string) {
|
|
if frontendRedirect == "" {
|
|
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, "", -1)
|
|
return
|
|
}
|
|
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, frontendRedirect, gitHubAuthCookieMaxAgeSeconds)
|
|
}
|
|
|
|
func clearControlServiceAuthFlowState(c *gin.Context) {
|
|
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, "", -1)
|
|
}
|
|
|
|
func getControlServiceFrontendRedirectFromCookie(c *gin.Context) string {
|
|
raw, err := c.Cookie(controlServiceFrontendRedirectCookieName)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return normalizeFrontendRedirectURL(raw)
|
|
}
|
|
|
|
func buildControlServiceCallbackURL(r *http.Request) string {
|
|
baseURL := backendPublicBaseURL(r)
|
|
if baseURL == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(baseURL, "/") + "/api/v1/auth/control/callback"
|
|
}
|
|
|
|
func buildGitHubAppInstallCallbackURL(r *http.Request, state string) string {
|
|
baseURL := backendPublicBaseURL(r)
|
|
if baseURL == "" {
|
|
return ""
|
|
}
|
|
|
|
callbackURL, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/github/app/callback")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
query := callbackURL.Query()
|
|
query.Set("state", state)
|
|
callbackURL.RawQuery = query.Encode()
|
|
return callbackURL.String()
|
|
}
|
|
|
|
func buildControlServiceGitHubStartURL(r *http.Request) (string, error) {
|
|
callbackURL := buildControlServiceCallbackURL(r)
|
|
if callbackURL == "" {
|
|
return "", errors.New("unable to determine local OAuth callback URL")
|
|
}
|
|
|
|
parsed, err := url.Parse(strings.TrimRight(controlServiceBaseURL, "/") + "/auth/github")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := parsed.Query()
|
|
query.Set("redirect_uri", callbackURL)
|
|
parsed.RawQuery = query.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func controlServiceClient() *http.Client {
|
|
return &http.Client{Timeout: controlServiceRequestTimeout}
|
|
}
|
|
|
|
func parseControlServiceError(statusCode int, body []byte) error {
|
|
var payload controlServiceErrorResponse
|
|
if err := json.Unmarshal(body, &payload); err == nil && strings.TrimSpace(payload.Error) != "" {
|
|
return fmt.Errorf("control service returned %d: %s", statusCode, payload.Error)
|
|
}
|
|
message := strings.TrimSpace(string(body))
|
|
if message == "" {
|
|
message = http.StatusText(statusCode)
|
|
}
|
|
return fmt.Errorf("control service returned %d: %s", statusCode, truncateString(message, 220))
|
|
}
|
|
|
|
func validateControlServiceToken(ctx context.Context, token string) (*controlServiceTokenValidationResponse, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, errors.New("controller token is required")
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{"token": token})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
strings.TrimRight(controlServiceBaseURL, "/")+"/api/v1/auth/control/callback",
|
|
bytes.NewReader(payload),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "Trackeep")
|
|
|
|
resp, err := controlServiceClient().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, parseControlServiceError(resp.StatusCode, body)
|
|
}
|
|
|
|
var parsed controlServiceTokenValidationResponse
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(parsed.Token) == "" {
|
|
parsed.Token = token
|
|
}
|
|
return &parsed, nil
|
|
}
|
|
|
|
func upsertControlServiceSession(db *gorm.DB, userID uint, controllerUser centralizedOAuthUser, token string) error {
|
|
if db == nil {
|
|
return errors.New("database not available")
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
return errors.New("controller token is required")
|
|
}
|
|
|
|
encryptedToken, err := utils.Encrypt(token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt controller token: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
var existing models.ControlServiceSession
|
|
lookupErr := db.Where("user_id = ?", userID).First(&existing).Error
|
|
|
|
switch {
|
|
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
|
record := models.ControlServiceSession{
|
|
UserID: userID,
|
|
ControllerUserID: controllerUser.ID,
|
|
GitHubID: controllerUser.GitHubID,
|
|
Username: controllerUser.Username,
|
|
Email: controllerUser.Email,
|
|
Token: encryptedToken,
|
|
LastValidatedAt: &now,
|
|
}
|
|
return db.Create(&record).Error
|
|
case lookupErr != nil:
|
|
return lookupErr
|
|
default:
|
|
return db.Model(&existing).Updates(map[string]interface{}{
|
|
"controller_user_id": controllerUser.ID,
|
|
"github_id": controllerUser.GitHubID,
|
|
"username": controllerUser.Username,
|
|
"email": controllerUser.Email,
|
|
"token": encryptedToken,
|
|
"last_validated_at": &now,
|
|
}).Error
|
|
}
|
|
}
|
|
|
|
func getControlServiceSessionRecord(db *gorm.DB, userID uint) (*models.ControlServiceSession, error) {
|
|
var session models.ControlServiceSession
|
|
if err := db.Where("user_id = ?", userID).First(&session).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &session, nil
|
|
}
|
|
|
|
func getControlServiceTokenForUser(db *gorm.DB, userID uint) (string, error) {
|
|
session, err := getControlServiceSessionRecord(db, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token, err := utils.Decrypt(session.Token)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decrypt controller token: %w", err)
|
|
}
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return "", errors.New("controller token is empty")
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func persistControlServiceToken(db *gorm.DB, userID uint, token string) error {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
|
|
encryptedToken, err := utils.Encrypt(token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt refreshed controller token: %w", err)
|
|
}
|
|
now := time.Now()
|
|
return db.Model(&models.ControlServiceSession{}).
|
|
Where("user_id = ?", userID).
|
|
Updates(map[string]interface{}{
|
|
"token": encryptedToken,
|
|
"last_validated_at": &now,
|
|
}).Error
|
|
}
|
|
|
|
func performControlServiceRequest(
|
|
ctx context.Context,
|
|
db *gorm.DB,
|
|
userID uint,
|
|
method string,
|
|
path string,
|
|
body io.Reader,
|
|
contentType string,
|
|
) ([]byte, http.Header, error) {
|
|
token, err := getControlServiceTokenForUser(db, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(controlServiceBaseURL, "/")+path, body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "Trackeep")
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
resp, err := controlServiceClient().Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if refreshedToken := strings.TrimSpace(resp.Header.Get(controlServiceSessionTokenHeader)); refreshedToken != "" {
|
|
_ = persistControlServiceToken(db, userID, refreshedToken)
|
|
}
|
|
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, resp.Header, parseControlServiceError(resp.StatusCode, responseBody)
|
|
}
|
|
|
|
return responseBody, resp.Header, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubRepos(ctx context.Context, db *gorm.DB, userID uint) ([]GitHubRepo, error) {
|
|
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/repos", nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload struct {
|
|
Repos []GitHubRepo `json:"repos"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return payload.Repos, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubAppInfo(ctx context.Context, db *gorm.DB, userID uint) (*controlServiceGitHubAppInfo, error) {
|
|
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/app/info", nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload controlServiceGitHubAppInfo
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return &payload, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubAppInstallURL(ctx context.Context, db *gorm.DB, userID uint, redirectURL string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimRight(controlServiceBaseURL, "/") + "/api/v1/github/app/install-url")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
query := parsed.Query()
|
|
query.Set("redirect_uri", redirectURL)
|
|
parsed.RawQuery = query.Encode()
|
|
|
|
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, strings.TrimPrefix(parsed.String(), strings.TrimRight(controlServiceBaseURL, "/")), nil, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var payload struct {
|
|
InstallURL string `json:"install_url"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(payload.InstallURL) == "" {
|
|
return "", errors.New("control service did not return an install URL")
|
|
}
|
|
return payload.InstallURL, nil
|
|
}
|
|
|
|
func verifyControlServiceGitHubInstallation(ctx context.Context, db *gorm.DB, userID uint, installationID int64) (*controlServiceInstallationVerification, error) {
|
|
body, _, err := performControlServiceRequest(
|
|
ctx,
|
|
db,
|
|
userID,
|
|
http.MethodGet,
|
|
fmt.Sprintf("/api/v1/github/app/installations/%d/verify", installationID),
|
|
nil,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload controlServiceInstallationVerification
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if !payload.Verified {
|
|
return nil, errors.New("control service could not verify the GitHub installation")
|
|
}
|
|
return &payload, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubAppRepos(ctx context.Context, db *gorm.DB, userID uint, installationID int64) ([]GitHubRepo, error) {
|
|
body, _, err := performControlServiceRequest(
|
|
ctx,
|
|
db,
|
|
userID,
|
|
http.MethodGet,
|
|
fmt.Sprintf("/api/v1/github/app/installations/%d/repos", installationID),
|
|
nil,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload struct {
|
|
Repositories []GitHubRepo `json:"repositories"`
|
|
Repos []GitHubRepo `json:"repos"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(payload.Repositories) > 0 {
|
|
return payload.Repositories, nil
|
|
}
|
|
return payload.Repos, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubUserAccessToken(ctx context.Context, db *gorm.DB, userID uint) (*controlServiceAccessTokenPayload, error) {
|
|
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/user/access-token", nil, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload controlServiceAccessTokenPayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(payload.AccessToken) == "" {
|
|
return nil, errors.New("control service returned an empty GitHub user token")
|
|
}
|
|
return &payload, nil
|
|
}
|
|
|
|
func fetchControlServiceGitHubInstallationAccessToken(ctx context.Context, db *gorm.DB, userID uint, installationID int64) (*controlServiceAccessTokenPayload, error) {
|
|
body, _, err := performControlServiceRequest(
|
|
ctx,
|
|
db,
|
|
userID,
|
|
http.MethodGet,
|
|
fmt.Sprintf("/api/v1/github/app/installations/%d/access-token", installationID),
|
|
nil,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload controlServiceAccessTokenPayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(payload.AccessToken) == "" {
|
|
return nil, errors.New("control service returned an empty GitHub installation token")
|
|
}
|
|
return &payload, nil
|
|
}
|
|
|
|
// HandleOAuthCallback exchanges a hq.trackeep.org token for a local Trackeep session.
|
|
func HandleOAuthCallback(c *gin.Context) {
|
|
frontendRedirect := getControlServiceFrontendRedirectFromCookie(c)
|
|
clearControlServiceAuthFlowState(c)
|
|
|
|
token := strings.TrimSpace(c.Query("token"))
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Controller token is required"})
|
|
return
|
|
}
|
|
|
|
validation, err := validateControlServiceToken(c.Request.Context(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
db := config.GetDB()
|
|
if db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
|
return
|
|
}
|
|
|
|
user, err := upsertCentralizedOAuthUser(db, validation.User)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
|
return
|
|
}
|
|
|
|
if err := upsertControlServiceSession(db, user.ID, validation.User, validation.Token); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store controller session"})
|
|
return
|
|
}
|
|
|
|
localToken, err := GenerateJWT(*user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Trackeep token"})
|
|
return
|
|
}
|
|
|
|
redirectURL := buildFrontendCallbackRedirectURL(frontendRedirect, localToken)
|
|
if redirectURL == "" {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
|
}
|
|
|
|
func generateRandomString(length int) string {
|
|
bytes := make([]byte, length)
|
|
_, _ = rand.Read(bytes)
|
|
return hex.EncodeToString(bytes)
|
|
}
|