mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-08 08:21:12 +02:00
1 parent
cff927d477
commit
f6eb87fa2b
31 files changed
+2627
-60
No files matched your search
@@ -188,6 +188,12 @@ const (
|
||||
ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test"
|
||||
// identifiers
|
||||
ROUTE_V1_IDENTIFIER = "/api/v1/identifier"
|
||||
// oauth providers
|
||||
ROUTE_V1_OAUTH_PROVIDER = "/api/v1/oauth-provider"
|
||||
ROUTE_V1_OAUTH_PROVIDER_ID = "/api/v1/oauth-provider/:id"
|
||||
ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH = "/api/v1/oauth-provider/:id/remove-authorization"
|
||||
ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id"
|
||||
ROUTE_V1_OAUTH_CALLBACK = "/api/v1/oauth-callback"
|
||||
// license
|
||||
ROUTE_V1_LICENSE = "/api/v1/license"
|
||||
// version
|
||||
@@ -362,6 +368,15 @@ func setupRoutes(
|
||||
// smtp configuration headers
|
||||
PATCH(ROUTE_V1_SMTP_CONFIGURATION_HEADERS, middleware.SessionHandler, controllers.SMTPConfiguration.AddHeader).
|
||||
DELETE(ROUTE_V1_SMTP_HEADER_ID, middleware.SessionHandler, controllers.SMTPConfiguration.RemoveHeader).
|
||||
// oauth providers
|
||||
GET(ROUTE_V1_OAUTH_PROVIDER, middleware.SessionHandler, controllers.OAuthProvider.GetAll).
|
||||
GET(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.GetByID).
|
||||
POST(ROUTE_V1_OAUTH_PROVIDER, middleware.SessionHandler, controllers.OAuthProvider.Create).
|
||||
PATCH(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.UpdateByID).
|
||||
DELETE(ROUTE_V1_OAUTH_PROVIDER_ID, middleware.SessionHandler, controllers.OAuthProvider.DeleteByID).
|
||||
POST(ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH, middleware.SessionHandler, controllers.OAuthProvider.RemoveAuthorization).
|
||||
GET(ROUTE_V1_OAUTH_AUTHORIZE, middleware.SessionHandler, controllers.OAuthProvider.GetAuthorizationURL).
|
||||
GET(ROUTE_V1_OAUTH_CALLBACK, controllers.OAuthProvider.HandleCallback).
|
||||
// emails
|
||||
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
|
||||
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/phishingclub/phishingclub/config"
|
||||
"github.com/phishingclub/phishingclub/controller"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -38,6 +39,7 @@ type Controllers struct {
|
||||
Import *controller.Import
|
||||
Backup *controller.Backup
|
||||
IPAllowList *controller.IPAllowList
|
||||
OAuthProvider *controller.OAuthProvider
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -50,6 +52,7 @@ func NewControllers(
|
||||
atomLogger *zap.AtomicLevel,
|
||||
utillities *Utilities,
|
||||
db *gorm.DB,
|
||||
conf *config.Config,
|
||||
) *Controllers {
|
||||
common := controller.Common{
|
||||
SessionService: services.Session,
|
||||
@@ -188,6 +191,11 @@ func NewControllers(
|
||||
geoIP := &controller.GeoIP{
|
||||
Common: common,
|
||||
}
|
||||
oauthProvider := &controller.OAuthProvider{
|
||||
Common: common,
|
||||
OAuthProviderService: services.OAuthProvider,
|
||||
Config: conf,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -220,5 +228,6 @@ func NewControllers(
|
||||
Import: importController,
|
||||
Backup: backup,
|
||||
IPAllowList: ipAllowList,
|
||||
OAuthProvider: oauthProvider,
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ type Repositories struct {
|
||||
AllowDeny *repository.AllowDeny
|
||||
Webhook *repository.Webhook
|
||||
Identifier *repository.Identifier
|
||||
OAuthProvider *repository.OAuthProvider
|
||||
OAuthState *repository.OAuthState
|
||||
}
|
||||
|
||||
// NewRepositories creates a collection of repositories
|
||||
@@ -57,5 +59,7 @@ func NewRepositories(
|
||||
AllowDeny: &repository.AllowDeny{DB: db},
|
||||
Webhook: &repository.Webhook{DB: db},
|
||||
Identifier: &repository.Identifier{DB: db},
|
||||
OAuthProvider: &repository.OAuthProvider{DB: db},
|
||||
OAuthState: &repository.OAuthState{DB: db},
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type Services struct {
|
||||
Backup *service.Backup
|
||||
IPAllowList *service.IPAllowListService
|
||||
ProxySessionManager *service.ProxySessionManager
|
||||
OAuthProvider *service.OAuthProvider
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -248,6 +249,14 @@ func NewServices(
|
||||
EmailRepository: repositories.Email,
|
||||
PageRepository: repositories.Page,
|
||||
}
|
||||
oauthProvider := &service.OAuthProvider{
|
||||
Common: common,
|
||||
OAuthProviderRepository: repositories.OAuthProvider,
|
||||
OAuthStateRepository: repositories.OAuthState,
|
||||
}
|
||||
|
||||
// inject oauth provider service into api sender
|
||||
apiSender.OAuthProviderService = oauthProvider
|
||||
|
||||
return &Services{
|
||||
Asset: asset,
|
||||
@@ -279,5 +288,6 @@ func NewServices(
|
||||
Backup: backupService,
|
||||
IPAllowList: ipAllowListService,
|
||||
ProxySessionManager: proxySessionManager,
|
||||
OAuthProvider: oauthProvider,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/phishingclub/phishingclub/build"
|
||||
"github.com/phishingclub/phishingclub/config"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"github.com/phishingclub/phishingclub/service"
|
||||
)
|
||||
|
||||
// OAuthProviderColumnsMap is a map between the frontend and the backend
|
||||
var OAuthProviderColumnsMap = map[string]string{
|
||||
"created_at": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "created_at"),
|
||||
"updated_at": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "updated_at"),
|
||||
"name": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "name"),
|
||||
"is_authorized": repository.TableColumn(database.OAUTH_PROVIDER_TABLE, "is_authorized"),
|
||||
}
|
||||
|
||||
// OAuthProvider is a controller
|
||||
type OAuthProvider struct {
|
||||
Common
|
||||
OAuthProviderService *service.OAuthProvider
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Create creates a new oauth provider
|
||||
func (c *OAuthProvider) Create(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
var req model.OAuthProvider
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
// save oauth provider
|
||||
id, err := c.OAuthProviderService.Create(g, session, &req)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(
|
||||
g,
|
||||
gin.H{
|
||||
"id": id.String(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetAll gets oauth providers
|
||||
func (c *OAuthProvider) GetAll(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
queryArgs, ok := c.handleQueryArgs(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
queryArgs.DefaultSortByUpdatedAt()
|
||||
queryArgs.RemapOrderBy(OAuthProviderColumnsMap)
|
||||
companyID := companyIDFromRequestQuery(g)
|
||||
// get
|
||||
providers, err := c.OAuthProviderService.GetAll(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
companyID,
|
||||
repository.OAuthProviderOption{
|
||||
Limit: &queryArgs.Limit,
|
||||
Offset: &queryArgs.Offset,
|
||||
Search: &queryArgs.Search,
|
||||
},
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, providers)
|
||||
}
|
||||
|
||||
// GetByID gets an oauth provider by id
|
||||
func (c *OAuthProvider) GetByID(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// get
|
||||
provider, err := c.OAuthProviderService.GetByID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, provider)
|
||||
}
|
||||
|
||||
// UpdateByID updates an oauth provider by id
|
||||
func (c *OAuthProvider) UpdateByID(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
var req model.OAuthProvider
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
// update
|
||||
err := c.OAuthProviderService.UpdateByID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
&req,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{"message": "updated"})
|
||||
}
|
||||
|
||||
// DeleteByID deletes an oauth provider by id
|
||||
func (c *OAuthProvider) DeleteByID(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// delete
|
||||
err := c.OAuthProviderService.DeleteByID(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// RemoveAuthorization removes authorization tokens from an oauth provider
|
||||
func (c *OAuthProvider) RemoveAuthorization(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// remove authorization
|
||||
err := c.OAuthProviderService.RemoveAuthorization(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{"message": "authorization removed"})
|
||||
}
|
||||
|
||||
// GetAuthorizationURL generates the oauth authorization url for user to visit
|
||||
func (c *OAuthProvider) GetAuthorizationURL(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// construct redirect uri from config (secure - not user-controllable)
|
||||
var host string
|
||||
if build.Flags.Production {
|
||||
host = c.Config.TLSHost()
|
||||
} else {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
adminPort := c.Config.AdminNetAddressPort()
|
||||
|
||||
var redirectURI string
|
||||
if adminPort == 443 || adminPort == 0 {
|
||||
// standard https port or ephemeral port, no need to include in url
|
||||
redirectURI = fmt.Sprintf("https://%s/api/v1/oauth-callback", host)
|
||||
} else {
|
||||
// non-standard port, include it
|
||||
redirectURI = fmt.Sprintf("https://%s:%d/api/v1/oauth-callback", host, adminPort)
|
||||
}
|
||||
|
||||
// get authorization url
|
||||
authURL, err := c.OAuthProviderService.GetAuthorizationURL(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
redirectURI,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, gin.H{"authorizationURL": authURL})
|
||||
}
|
||||
|
||||
// HandleCallback handles the oauth callback from the provider
|
||||
// note: this endpoint is PUBLIC (no session required) because oauth providers call it from cross-site context
|
||||
func (c *OAuthProvider) HandleCallback(g *gin.Context) {
|
||||
code := g.Query("code")
|
||||
state := g.Query("state")
|
||||
errorParam := g.Query("error")
|
||||
|
||||
// handle oauth errors from provider (don't expose error details to user)
|
||||
if errorParam != "" {
|
||||
errorDesc := g.Query("error_description")
|
||||
c.Logger.Warnw("oauth provider returned error", "error", errorParam, "description", errorDesc)
|
||||
c.renderCallbackPage(g, false, "provider_error")
|
||||
return
|
||||
}
|
||||
|
||||
// validate parameters
|
||||
if code == "" || state == "" {
|
||||
c.Logger.Warnw("oauth callback missing required parameters")
|
||||
c.renderCallbackPage(g, false, "invalid_request")
|
||||
return
|
||||
}
|
||||
|
||||
// construct redirect uri from config (must match the one used in authorization)
|
||||
var host string
|
||||
if build.Flags.Production {
|
||||
host = c.Config.TLSHost()
|
||||
} else {
|
||||
host = "localhost"
|
||||
}
|
||||
|
||||
adminPort := c.Config.AdminNetAddressPort()
|
||||
|
||||
var redirectURI string
|
||||
if adminPort == 443 || adminPort == 0 {
|
||||
// standard https port or ephemeral port, no need to include in url
|
||||
redirectURI = fmt.Sprintf("https://%s/api/v1/oauth-callback", host)
|
||||
} else {
|
||||
// non-standard port, include it
|
||||
redirectURI = fmt.Sprintf("https://%s:%d/api/v1/oauth-callback", host, adminPort)
|
||||
}
|
||||
|
||||
// exchange code for tokens
|
||||
// session is nil because callback is public (cross-site context doesn't send cookies)
|
||||
// validation happens through state token lookup (bound to initiating session)
|
||||
if err := c.OAuthProviderService.ExchangeCodeForTokens(
|
||||
g.Request.Context(),
|
||||
nil, // no session - callback is public
|
||||
state,
|
||||
code,
|
||||
redirectURI,
|
||||
); err != nil {
|
||||
// log detailed error internally, show generic error to user
|
||||
c.Logger.Warnw("failed to exchange code for tokens", "reason", err)
|
||||
c.renderCallbackPage(g, false, "token_exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
// success - render success page
|
||||
c.renderCallbackPage(g, true, "")
|
||||
}
|
||||
|
||||
// renderCallbackPage renders a plain text page for oauth callback result
|
||||
// this page is shown in the popup window, notifies the parent, and instructs user to close it
|
||||
func (c *OAuthProvider) renderCallbackPage(g *gin.Context, success bool, errorCode string) {
|
||||
var text string
|
||||
var status string
|
||||
|
||||
// define allowed error codes and their user-friendly messages
|
||||
allowedErrors := map[string]string{
|
||||
"provider_error": "The OAuth provider returned an error",
|
||||
"invalid_request": "Invalid authorization request",
|
||||
"token_exchange_failed": "Failed to exchange authorization code for tokens",
|
||||
}
|
||||
|
||||
if success {
|
||||
text = "OAuth authorization successful!\n\nYou can close this window now."
|
||||
status = "success"
|
||||
} else {
|
||||
// validate error code - only use allowed values
|
||||
userMessage, ok := allowedErrors[errorCode]
|
||||
if !ok {
|
||||
// if error code is not in allowed list, use generic message
|
||||
c.Logger.Warnw("invalid error code passed to renderCallbackPage", "errorCode", errorCode)
|
||||
userMessage = "An unexpected error occurred"
|
||||
errorCode = "unknown_error"
|
||||
}
|
||||
text = fmt.Sprintf("OAuth authorization failed.\n\n%s\n\nYou can close this window now.", userMessage)
|
||||
status = "error"
|
||||
}
|
||||
|
||||
// determine target origin for postMessage
|
||||
// in dev, use wildcard for localhost to handle vite proxy (frontend on :8003, backend on :8002)
|
||||
// in production, use specific origin for security
|
||||
var targetOrigin string
|
||||
if build.Flags.Production {
|
||||
targetOrigin = "window.location.origin"
|
||||
} else {
|
||||
// in dev, check if we're on localhost and use wildcard
|
||||
targetOrigin = "(window.location.hostname === 'localhost' ? '*' : window.location.origin)"
|
||||
}
|
||||
|
||||
// html with script to notify parent window and plain text display
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<pre>%s</pre>
|
||||
<script>
|
||||
console.log('OAuth callback page loaded with status: %s');
|
||||
if (window.opener) {
|
||||
console.log('Sending message to parent window');
|
||||
try {
|
||||
var targetOrigin = %s;
|
||||
console.log('Target origin:', targetOrigin);
|
||||
window.opener.postMessage({
|
||||
type: 'oauth-callback',
|
||||
status: '%s'
|
||||
}, targetOrigin);
|
||||
console.log('Message sent successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to send message:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('No window.opener found');
|
||||
}
|
||||
|
||||
// auto-close popup after 5 seconds
|
||||
setTimeout(function() {
|
||||
console.log('Auto-closing popup window');
|
||||
window.close();
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`, text, status, targetOrigin, status)
|
||||
|
||||
g.Header("Content-Type", "text/html; charset=utf-8")
|
||||
g.String(http.StatusOK, html)
|
||||
}
|
||||
@@ -25,6 +25,10 @@ type APISender struct {
|
||||
CustomField3 string
|
||||
CustomField4 string
|
||||
|
||||
// oauth provider for token-based authentication
|
||||
OAuthProviderID *uuid.UUID `gorm:"type:uuid;index;"`
|
||||
OAuthProvider *OAuthProvider `gorm:"foreignKey:OAuthProviderID"`
|
||||
|
||||
// Request fields
|
||||
RequestMethod string
|
||||
RequestURL string
|
||||
@@ -38,6 +42,13 @@ type APISender struct {
|
||||
}
|
||||
|
||||
func (e *APISender) Migrate(db *gorm.DB) error {
|
||||
// add o_auth_provider_id column if it doesn't exist
|
||||
if !db.Migrator().HasColumn(&APISender{}, "o_auth_provider_id") {
|
||||
if err := db.Migrator().AddColumn(&APISender{}, "OAuthProviderID"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// SQLITE
|
||||
// ensure name + null company id is unique
|
||||
return UniqueIndexNameAndNullCompanyID(db, "api_senders")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
OAUTH_PROVIDER_TABLE = "oauth_providers"
|
||||
)
|
||||
|
||||
// OAuthProvider is the gorm data model for oauth providers
|
||||
type OAuthProvider struct {
|
||||
ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
UpdatedAt *time.Time `gorm:"not null;index;"`
|
||||
|
||||
Name string `gorm:"not null;uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
|
||||
|
||||
// oauth endpoints (user configurable)
|
||||
AuthURL string `gorm:"not null;type:varchar(512);"`
|
||||
TokenURL string `gorm:"not null;type:varchar(512);"`
|
||||
Scopes string `gorm:"not null;type:varchar(512);"`
|
||||
|
||||
// user's oauth app credentials (stored as plain text like smtp passwords)
|
||||
ClientID string `gorm:"not null;type:varchar(255);"`
|
||||
ClientSecret string `gorm:"not null;type:varchar(255);"`
|
||||
|
||||
// current token state (stored as plain text)
|
||||
AccessToken string `gorm:"type:varchar(4096);"`
|
||||
RefreshToken string `gorm:"type:varchar(4096);"`
|
||||
TokenExpiresAt *time.Time `gorm:"index;"`
|
||||
|
||||
// authorization metadata
|
||||
AuthorizedEmail string `gorm:"type:varchar(255);"`
|
||||
AuthorizedAt *time.Time `gorm:"index;"`
|
||||
|
||||
// status
|
||||
IsAuthorized bool `gorm:"not null;default:false;"`
|
||||
|
||||
// can belong-to
|
||||
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
|
||||
Company *Company `gorm:"foreignkey:CompanyID;"`
|
||||
}
|
||||
|
||||
func (o *OAuthProvider) Migrate(db *gorm.DB) error {
|
||||
// ensure name + company id is unique
|
||||
return UniqueIndexNameAndNullCompanyID(db, "oauth_providers")
|
||||
}
|
||||
|
||||
func (OAuthProvider) TableName() string {
|
||||
return OAUTH_PROVIDER_TABLE
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
OAUTH_STATE_TABLE = "oauth_states"
|
||||
)
|
||||
|
||||
// OAuthState stores temporary state tokens for oauth flows
|
||||
// used for csrf protection
|
||||
type OAuthState struct {
|
||||
ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
|
||||
// the state token sent to oauth provider (random cryptographic token)
|
||||
StateToken string `gorm:"not null;uniqueIndex;type:varchar(255);"`
|
||||
|
||||
// the oauth provider this state is for
|
||||
OAuthProviderID uuid.UUID `gorm:"not null;index;type:uuid"`
|
||||
OAuthProvider *OAuthProvider `gorm:"foreignkey:OAuthProviderID;"`
|
||||
|
||||
// expiration (state tokens expire after 10 minutes)
|
||||
ExpiresAt *time.Time `gorm:"not null;index;"`
|
||||
|
||||
// whether this state token has been used (prevent replay attacks)
|
||||
Used bool `gorm:"not null;default:false;index;"`
|
||||
UsedAt *time.Time `gorm:"index;"`
|
||||
}
|
||||
|
||||
func (OAuthState) TableName() string {
|
||||
return OAUTH_STATE_TABLE
|
||||
}
|
||||
+3
-3
@@ -5,6 +5,7 @@ go 1.25.1
|
||||
require (
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/boombuler/barcode v1.0.1
|
||||
github.com/brianvoe/gofakeit/v7 v7.0.4
|
||||
github.com/caddyserver/certmagic v0.19.2
|
||||
@@ -18,6 +19,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/oapi-codegen/nullable v1.1.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
@@ -27,6 +29,7 @@ require (
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/mod v0.29.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
@@ -35,7 +38,6 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -71,7 +73,6 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -106,7 +107,6 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
|
||||
+2
-2
@@ -270,8 +270,8 @@ golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -266,6 +266,7 @@ func main() {
|
||||
atomicLogger,
|
||||
utils,
|
||||
db,
|
||||
conf,
|
||||
)
|
||||
// setup admin account
|
||||
isInstalled, err := controllers.InitialSetup.IsInstalled(context.Background())
|
||||
|
||||
@@ -26,6 +26,8 @@ type APISender struct {
|
||||
CustomField2 nullable.Nullable[vo.OptionalString255] `json:"customField2"`
|
||||
CustomField3 nullable.Nullable[vo.OptionalString255] `json:"customField3"`
|
||||
CustomField4 nullable.Nullable[vo.OptionalString255] `json:"customField4"`
|
||||
OAuthProviderID nullable.Nullable[uuid.UUID] `json:"oauthProviderID"`
|
||||
OAuthProvider *OAuthProvider `json:"oauthProvider"`
|
||||
RequestMethod nullable.Nullable[vo.HTTPMethod] `json:"requestMethod"`
|
||||
RequestURL nullable.Nullable[vo.String255] `json:"requestURL"`
|
||||
RequestHeaders nullable.Nullable[APISenderHeaders] `json:"requestHeaders"`
|
||||
@@ -149,6 +151,13 @@ func (a *APISender) ToDBMap() map[string]interface{} {
|
||||
m["expected_response_body"] = expectedResponseBody.String()
|
||||
}
|
||||
}
|
||||
if a.OAuthProviderID.IsSpecified() {
|
||||
if a.OAuthProviderID.IsNull() {
|
||||
m["o_auth_provider_id"] = nil
|
||||
} else {
|
||||
m["o_auth_provider_id"] = a.OAuthProviderID.MustGet()
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
|
||||
// OAuthProvider is a user-configured OAuth 2.0 provider
|
||||
type OAuthProvider struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
|
||||
Name nullable.Nullable[vo.String127] `json:"name"`
|
||||
|
||||
// oauth endpoints (user configurable)
|
||||
AuthURL nullable.Nullable[vo.String512] `json:"authURL"`
|
||||
TokenURL nullable.Nullable[vo.String512] `json:"tokenURL"`
|
||||
Scopes nullable.Nullable[vo.String512] `json:"scopes"`
|
||||
|
||||
// user's oauth app credentials
|
||||
ClientID nullable.Nullable[vo.String255] `json:"clientID"`
|
||||
ClientSecret nullable.Nullable[vo.OptionalString255] `json:"clientSecret"` // write-only, never returned
|
||||
|
||||
// current token state (stored as plain text like smtp passwords)
|
||||
AccessToken nullable.Nullable[vo.OptionalString1MB] `json:"-"` // never returned in api
|
||||
RefreshToken nullable.Nullable[vo.OptionalString1MB] `json:"-"` // never returned in api
|
||||
TokenExpiresAt *time.Time `json:"tokenExpiresAt"`
|
||||
|
||||
// authorization metadata
|
||||
AuthorizedEmail nullable.Nullable[vo.OptionalString255] `json:"authorizedEmail"` // email of the account that authorized
|
||||
AuthorizedAt *time.Time `json:"authorizedAt"`
|
||||
|
||||
// status
|
||||
IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed
|
||||
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Company *Company `json:"company"`
|
||||
}
|
||||
|
||||
// Validate checks if the oauth provider has a valid state
|
||||
func (o *OAuthProvider) Validate() error {
|
||||
if err := validate.NullableFieldRequired("name", o.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("authURL", o.AuthURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("tokenURL", o.TokenURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("scopes", o.Scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("clientID", o.ClientID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("clientSecret", o.ClientSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDBMap converts the fields that can be stored or updated to a map
|
||||
func (o *OAuthProvider) ToDBMap() map[string]any {
|
||||
m := map[string]any{}
|
||||
|
||||
if o.Name.IsSpecified() {
|
||||
m["name"] = nil
|
||||
if name, err := o.Name.Get(); err == nil {
|
||||
m["name"] = name.String()
|
||||
}
|
||||
}
|
||||
|
||||
if o.AuthURL.IsSpecified() {
|
||||
m["auth_url"] = nil
|
||||
if authURL, err := o.AuthURL.Get(); err == nil {
|
||||
m["auth_url"] = authURL.String()
|
||||
}
|
||||
}
|
||||
|
||||
if o.TokenURL.IsSpecified() {
|
||||
m["token_url"] = nil
|
||||
if tokenURL, err := o.TokenURL.Get(); err == nil {
|
||||
m["token_url"] = tokenURL.String()
|
||||
}
|
||||
}
|
||||
|
||||
if o.Scopes.IsSpecified() {
|
||||
m["scopes"] = nil
|
||||
if scopes, err := o.Scopes.Get(); err == nil {
|
||||
m["scopes"] = scopes.String()
|
||||
}
|
||||
}
|
||||
|
||||
if o.ClientID.IsSpecified() {
|
||||
m["client_id"] = nil
|
||||
if clientID, err := o.ClientID.Get(); err == nil {
|
||||
m["client_id"] = clientID.String()
|
||||
}
|
||||
}
|
||||
|
||||
if o.ClientSecret.IsSpecified() {
|
||||
if o.ClientSecret.IsNull() {
|
||||
// don't update client secret if null
|
||||
} else {
|
||||
if v, err := o.ClientSecret.Get(); err == nil {
|
||||
// only update if non-empty
|
||||
if v.String() != "" {
|
||||
m["client_secret"] = v.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.AccessToken.IsSpecified() {
|
||||
if o.AccessToken.IsNull() {
|
||||
m["access_token"] = ""
|
||||
} else {
|
||||
if v, err := o.AccessToken.Get(); err == nil {
|
||||
m["access_token"] = v.String()
|
||||
} else {
|
||||
m["access_token"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.RefreshToken.IsSpecified() {
|
||||
if o.RefreshToken.IsNull() {
|
||||
m["refresh_token"] = ""
|
||||
} else {
|
||||
if v, err := o.RefreshToken.Get(); err == nil {
|
||||
m["refresh_token"] = v.String()
|
||||
} else {
|
||||
m["refresh_token"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if o.TokenExpiresAt != nil {
|
||||
m["token_expires_at"] = o.TokenExpiresAt
|
||||
}
|
||||
|
||||
if o.AuthorizedEmail.IsSpecified() {
|
||||
if o.AuthorizedEmail.IsNull() {
|
||||
m["authorized_email"] = ""
|
||||
} else {
|
||||
if v, err := o.AuthorizedEmail.Get(); err == nil {
|
||||
m["authorized_email"] = v.String()
|
||||
} else {
|
||||
m["authorized_email"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.AuthorizedAt != nil {
|
||||
m["authorized_at"] = o.AuthorizedAt
|
||||
}
|
||||
|
||||
if o.IsAuthorized.IsSpecified() {
|
||||
m["is_authorized"] = nil
|
||||
if isAuthorized, err := o.IsAuthorized.Get(); err == nil {
|
||||
m["is_authorized"] = isAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
if o.CompanyID.IsSpecified() {
|
||||
if o.CompanyID.IsNull() {
|
||||
m["company_id"] = nil
|
||||
} else {
|
||||
m["company_id"] = o.CompanyID.MustGet()
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
|
||||
// OAuthState represents a temporary state token for oauth flow
|
||||
type OAuthState struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
|
||||
// the state token sent to oauth provider
|
||||
StateToken nullable.Nullable[vo.String255] `json:"stateToken"`
|
||||
|
||||
// the oauth provider this state is for
|
||||
OAuthProviderID nullable.Nullable[uuid.UUID] `json:"oauthProviderID"`
|
||||
OAuthProvider *OAuthProvider `json:"oauthProvider"`
|
||||
|
||||
// expiration
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
|
||||
// whether this state token has been used
|
||||
Used bool `json:"used"`
|
||||
UsedAt *time.Time `json:"usedAt"`
|
||||
}
|
||||
|
||||
// OAuthStateFromDB converts database model to model
|
||||
func OAuthStateFromDB(db *database.OAuthState) *OAuthState {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stateToken, err := vo.NewString255(db.StateToken)
|
||||
if err != nil {
|
||||
// fallback to empty if token is invalid (should not happen)
|
||||
stateToken = vo.NewString255Must("")
|
||||
}
|
||||
|
||||
state := &OAuthState{
|
||||
ID: nullable.NewNullableWithValue(db.ID),
|
||||
CreatedAt: db.CreatedAt,
|
||||
StateToken: nullable.NewNullableWithValue(*stateToken),
|
||||
OAuthProviderID: nullable.NewNullableWithValue(db.OAuthProviderID),
|
||||
ExpiresAt: db.ExpiresAt,
|
||||
Used: db.Used,
|
||||
UsedAt: db.UsedAt,
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type APISenderOption struct {
|
||||
|
||||
WithRequestHeaders bool
|
||||
WithResponseHeaders bool
|
||||
WithOAuthProvider bool
|
||||
}
|
||||
|
||||
// APISender is a API sender repository
|
||||
@@ -36,6 +37,9 @@ func (a *APISender) preload(o *APISenderOption, db *gorm.DB) *gorm.DB {
|
||||
if o == nil {
|
||||
return db
|
||||
}
|
||||
if o.WithOAuthProvider {
|
||||
db = db.Preload("OAuthProvider")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -318,6 +322,16 @@ func ToAPISender(row *database.APISender) (*model.APISender, error) {
|
||||
expectedResponseHeaders.SetUnspecified()
|
||||
}
|
||||
|
||||
oauthProviderID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.OAuthProviderID != nil {
|
||||
oauthProviderID.Set(*row.OAuthProviderID)
|
||||
}
|
||||
|
||||
var oauthProvider *model.OAuthProvider
|
||||
if row.OAuthProvider != nil {
|
||||
oauthProvider = ToOAuthProvider(row.OAuthProvider)
|
||||
}
|
||||
|
||||
return &model.APISender{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
@@ -329,6 +343,8 @@ func ToAPISender(row *database.APISender) (*model.APISender, error) {
|
||||
CustomField2: customField2,
|
||||
CustomField3: customField3,
|
||||
CustomField4: customField4,
|
||||
OAuthProviderID: oauthProviderID,
|
||||
OAuthProvider: oauthProvider,
|
||||
RequestMethod: requestMethod,
|
||||
RequestURL: requestURL,
|
||||
RequestHeaders: requestHeaders,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OAuthProvider is the repository for oauth providers
|
||||
type OAuthProvider struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// OAuthProviderOption is the option for getting oauth providers
|
||||
type OAuthProviderOption struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
Search *string
|
||||
}
|
||||
|
||||
// Insert inserts a new oauth provider
|
||||
func (o *OAuthProvider) Insert(ctx context.Context, provider *model.OAuthProvider) (*uuid.UUID, error) {
|
||||
m := provider.ToDBMap()
|
||||
now := time.Now()
|
||||
m["created_at"] = now
|
||||
m["updated_at"] = now
|
||||
id := uuid.New()
|
||||
m["id"] = id
|
||||
|
||||
if err := o.DB.WithContext(ctx).Table("oauth_providers").Create(m).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// GetAll gets all oauth providers with pagination
|
||||
func (o *OAuthProvider) GetAll(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
option *OAuthProviderOption,
|
||||
) (*model.Result[model.OAuthProvider], error) {
|
||||
var dbProviders []database.OAuthProvider
|
||||
var totalCount int64
|
||||
|
||||
query := o.DB.WithContext(ctx).Table("oauth_providers")
|
||||
|
||||
if companyID != nil {
|
||||
query = query.Where("company_id = ? OR company_id IS NULL", companyID)
|
||||
} else {
|
||||
query = query.Where("company_id IS NULL")
|
||||
}
|
||||
|
||||
if option.Search != nil && *option.Search != "" {
|
||||
search := "%" + *option.Search + "%"
|
||||
query = query.Where("name ILIKE ?", search)
|
||||
}
|
||||
|
||||
if err := query.Count(&totalCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query = query.Order("created_at DESC")
|
||||
|
||||
if option.Limit != nil {
|
||||
query = query.Limit(*option.Limit)
|
||||
}
|
||||
|
||||
if option.Offset != nil {
|
||||
query = query.Offset(*option.Offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&dbProviders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert database types to model types
|
||||
providers := make([]*model.OAuthProvider, len(dbProviders))
|
||||
for i := range dbProviders {
|
||||
providers[i] = ToOAuthProvider(&dbProviders[i])
|
||||
}
|
||||
|
||||
hasNextPage := false
|
||||
if option.Limit != nil && option.Offset != nil {
|
||||
hasNextPage = int64(*option.Offset+*option.Limit) < totalCount
|
||||
}
|
||||
|
||||
return &model.Result[model.OAuthProvider]{
|
||||
Rows: providers,
|
||||
HasNextPage: hasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByID gets an oauth provider by id
|
||||
func (o *OAuthProvider) GetByID(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
) (*model.OAuthProvider, error) {
|
||||
var dbProvider database.OAuthProvider
|
||||
|
||||
if err := o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("id = ?", id).
|
||||
First(&dbProvider).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ToOAuthProvider(&dbProvider), nil
|
||||
}
|
||||
|
||||
// GetByNameAndCompanyID gets an oauth provider by name and company id
|
||||
func (o *OAuthProvider) GetByNameAndCompanyID(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
companyID *uuid.UUID,
|
||||
) (*model.OAuthProvider, error) {
|
||||
var dbProvider database.OAuthProvider
|
||||
|
||||
query := o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("name = ?", name)
|
||||
|
||||
if companyID != nil {
|
||||
query = query.Where("company_id = ?", companyID)
|
||||
} else {
|
||||
query = query.Where("company_id IS NULL")
|
||||
}
|
||||
|
||||
if err := query.First(&dbProvider).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ToOAuthProvider(&dbProvider), nil
|
||||
}
|
||||
|
||||
// UpdateByID updates an oauth provider by id
|
||||
func (o *OAuthProvider) UpdateByID(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
provider *model.OAuthProvider,
|
||||
) error {
|
||||
m := provider.ToDBMap()
|
||||
m["updated_at"] = time.Now()
|
||||
|
||||
return o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("id = ?", id).
|
||||
Updates(m).Error
|
||||
}
|
||||
|
||||
// UpdateTokens updates the oauth tokens for a provider
|
||||
func (o *OAuthProvider) UpdateTokens(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
accessToken string,
|
||||
refreshToken string,
|
||||
expiresAt time.Time,
|
||||
) error {
|
||||
updates := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_expires_at": expiresAt,
|
||||
"is_authorized": true,
|
||||
"authorized_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
return o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// RemoveAuthorization removes authorization tokens from a provider
|
||||
func (o *OAuthProvider) RemoveAuthorization(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
) error {
|
||||
updates := map[string]interface{}{
|
||||
"access_token": nil,
|
||||
"refresh_token": nil,
|
||||
"token_expires_at": nil,
|
||||
"is_authorized": false,
|
||||
"authorized_at": nil,
|
||||
"authorized_email": nil,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
return o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteByID deletes an oauth provider by id
|
||||
func (o *OAuthProvider) DeleteByID(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
) error {
|
||||
return o.DB.WithContext(ctx).
|
||||
Table("oauth_providers").
|
||||
Where("id = ?", id).
|
||||
Delete(&model.OAuthProvider{}).Error
|
||||
}
|
||||
|
||||
// ToOAuthProvider converts database type to model type
|
||||
func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
id := nullable.NewNullableWithValue(row.ID)
|
||||
companyID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.CompanyID != nil {
|
||||
companyID.Set(*row.CompanyID)
|
||||
}
|
||||
name := nullable.NewNullableWithValue(*vo.NewString127Must(row.Name))
|
||||
authURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.AuthURL))
|
||||
tokenURL := nullable.NewNullableWithValue(*vo.NewString512Must(row.TokenURL))
|
||||
scopes := nullable.NewNullableWithValue(*vo.NewString512Must(row.Scopes))
|
||||
clientID := nullable.NewNullableWithValue(*vo.NewString255Must(row.ClientID))
|
||||
clientSecret := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.ClientSecret))
|
||||
accessToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.AccessToken))
|
||||
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
|
||||
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
|
||||
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
|
||||
|
||||
return &model.OAuthProvider{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
CompanyID: companyID,
|
||||
Name: name,
|
||||
AuthURL: authURL,
|
||||
TokenURL: tokenURL,
|
||||
Scopes: scopes,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenExpiresAt: row.TokenExpiresAt,
|
||||
AuthorizedEmail: authorizedEmail,
|
||||
AuthorizedAt: row.AuthorizedAt,
|
||||
IsAuthorized: isAuthorized,
|
||||
Company: nil,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OAuthState repository
|
||||
type OAuthState struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// Insert inserts a new oauth state token
|
||||
func (r *OAuthState) Insert(
|
||||
ctx context.Context,
|
||||
state *model.OAuthState,
|
||||
) (*uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
now := time.Now()
|
||||
dbState := &database.OAuthState{
|
||||
ID: id,
|
||||
CreatedAt: &now,
|
||||
StateToken: state.StateToken.MustGet().String(),
|
||||
OAuthProviderID: state.OAuthProviderID.MustGet(),
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
Used: false,
|
||||
}
|
||||
|
||||
result := r.DB.WithContext(ctx).Create(dbState)
|
||||
if result.Error != nil {
|
||||
return nil, errs.Wrap(result.Error)
|
||||
}
|
||||
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// GetByStateToken retrieves an oauth state by state token
|
||||
func (r *OAuthState) GetByStateToken(
|
||||
ctx context.Context,
|
||||
stateToken string,
|
||||
) (*model.OAuthState, error) {
|
||||
var dbState database.OAuthState
|
||||
result := r.DB.WithContext(ctx).
|
||||
Where("state_token = ?", stateToken).
|
||||
First(&dbState)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, errs.Wrap(result.Error)
|
||||
}
|
||||
|
||||
return r.toModel(&dbState), nil
|
||||
}
|
||||
|
||||
// MarkAsUsed marks a state token as used
|
||||
func (r *OAuthState) MarkAsUsed(
|
||||
ctx context.Context,
|
||||
id uuid.UUID,
|
||||
) error {
|
||||
now := time.Now()
|
||||
result := r.DB.WithContext(ctx).
|
||||
Model(&database.OAuthState{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"used": true,
|
||||
"used_at": &now,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return errs.Wrap(result.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpired deletes expired oauth state tokens
|
||||
func (r *OAuthState) DeleteExpired(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
result := r.DB.WithContext(ctx).
|
||||
Where("expires_at < ?", now).
|
||||
Delete(&database.OAuthState{})
|
||||
|
||||
if result.Error != nil {
|
||||
return errs.Wrap(result.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toModel converts database model to domain model
|
||||
func (r *OAuthState) toModel(dbState *database.OAuthState) *model.OAuthState {
|
||||
return model.OAuthStateFromDB(dbState)
|
||||
}
|
||||
@@ -53,6 +53,8 @@ func initialInstallAndSeed(
|
||||
&database.Webhook{},
|
||||
&database.Identifier{},
|
||||
&database.CampaignStats{},
|
||||
&database.OAuthProvider{},
|
||||
&database.OAuthState{},
|
||||
}
|
||||
|
||||
// disable foreign key constraints temporarily for sqlite to allow table recreation
|
||||
|
||||
@@ -31,6 +31,7 @@ type APISender struct {
|
||||
TemplateService *Template
|
||||
CampaignTemplateService *CampaignTemplate
|
||||
APISenderRepository *repository.APISender
|
||||
OAuthProviderService *OAuthProvider
|
||||
}
|
||||
|
||||
// APISenderTestResponse is a response for testing API sender
|
||||
@@ -285,6 +286,13 @@ func (a *APISender) UpdateByID(
|
||||
if v, err := incoming.Name.Get(); err == nil {
|
||||
current.Name.Set(v)
|
||||
}
|
||||
if incoming.CompanyID.IsSpecified() {
|
||||
if v, err := incoming.CompanyID.Get(); err == nil {
|
||||
current.CompanyID.Set(v)
|
||||
} else {
|
||||
current.CompanyID.SetNull()
|
||||
}
|
||||
}
|
||||
if v, err := incoming.APIKey.Get(); err == nil {
|
||||
current.APIKey.Set(v)
|
||||
}
|
||||
@@ -325,6 +333,13 @@ func (a *APISender) UpdateByID(
|
||||
if v, err := incoming.ExpectedResponseBody.Get(); err == nil {
|
||||
current.ExpectedResponseBody.Set(v)
|
||||
}
|
||||
if incoming.OAuthProviderID.IsSpecified() {
|
||||
if v, err := incoming.OAuthProviderID.Get(); err == nil {
|
||||
current.OAuthProviderID.Set(v)
|
||||
} else {
|
||||
current.OAuthProviderID.SetNull()
|
||||
}
|
||||
}
|
||||
if err := current.Validate(); err != nil {
|
||||
a.Logger.Errorw("failed to validate API sender", "error", err)
|
||||
return err
|
||||
@@ -399,12 +414,28 @@ func (a *APISender) SendTest(
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
a.Logger.Debugw("sending test request to API sender", "id", id.String())
|
||||
// get the API sender
|
||||
apiSender, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{})
|
||||
// get the API sender with oauth provider
|
||||
apiSender, err := a.APISenderRepository.GetByID(ctx, id, &repository.APISenderOption{
|
||||
WithOAuthProvider: true,
|
||||
})
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to get API sender by ID", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// get oauth access token if oauth provider is configured on the api sender
|
||||
var oauthAccessToken string
|
||||
oauthProviderID, err := apiSender.OAuthProviderID.Get()
|
||||
if err == nil && a.OAuthProviderService != nil {
|
||||
// oauth provider is configured for this api sender
|
||||
token, tokenErr := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID)
|
||||
if tokenErr != nil {
|
||||
a.Logger.Errorw("failed to get oauth access token for test", "error", tokenErr, "oauthProviderID", oauthProviderID)
|
||||
return nil, errs.Wrap(tokenErr)
|
||||
}
|
||||
oauthAccessToken = token
|
||||
a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID)
|
||||
}
|
||||
emailRaw := "bob@enterprise.test"
|
||||
email := *vo.NewEmailMust(emailRaw)
|
||||
cid := nullable.NewNullableWithValue(uuid.New())
|
||||
@@ -469,13 +500,15 @@ func (a *APISender) SendTest(
|
||||
},
|
||||
},
|
||||
}
|
||||
url, headers, body, err := a.buildRequest(
|
||||
url, headers, body, err := a.buildRequestWithCustomURL(
|
||||
apiSender,
|
||||
"api-sender-test.test",
|
||||
"id",
|
||||
"foo/bar",
|
||||
testCampaignRecipient,
|
||||
testEmail,
|
||||
"",
|
||||
oauthAccessToken,
|
||||
)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to build test request", "error", err)
|
||||
@@ -556,7 +589,9 @@ func (a *APISender) SendWithCustomURL(
|
||||
ctx,
|
||||
session,
|
||||
&apiSenderID,
|
||||
&repository.APISenderOption{},
|
||||
&repository.APISenderOption{
|
||||
WithOAuthProvider: true,
|
||||
},
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("api sender did not load: %s", err)
|
||||
@@ -565,6 +600,20 @@ func (a *APISender) SendWithCustomURL(
|
||||
return errors.New("api sender did not load")
|
||||
}
|
||||
|
||||
// get oauth access token if oauth provider is configured on the api sender
|
||||
var oauthAccessToken string
|
||||
oauthProviderID, err := apiSender.OAuthProviderID.Get()
|
||||
if err == nil && a.OAuthProviderService != nil {
|
||||
// oauth provider is configured for this api sender
|
||||
token, err := a.OAuthProviderService.GetValidAccessToken(ctx, oauthProviderID)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to get oauth access token", "error", err, "oauthProviderID", oauthProviderID)
|
||||
return fmt.Errorf("failed to get oauth access token: %w", err)
|
||||
}
|
||||
oauthAccessToken = token
|
||||
a.Logger.Debugw("got oauth access token for api request", "oauthProviderID", oauthProviderID)
|
||||
}
|
||||
|
||||
domainName := domain.Name.MustGet()
|
||||
urlIdentifier := cTemplate.URLIdentifier
|
||||
if urlIdentifier == nil {
|
||||
@@ -579,7 +628,9 @@ func (a *APISender) SendWithCustomURL(
|
||||
campaignRecipient,
|
||||
email,
|
||||
customCampaignURL,
|
||||
oauthAccessToken,
|
||||
)
|
||||
|
||||
resp, respBodyClose, err := a.sendRequest(
|
||||
context.Background(),
|
||||
apiSender,
|
||||
@@ -740,7 +791,7 @@ func (a *APISender) buildRequest(
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
email *model.Email, // todo is this superfluous? it should be in the campaign recipient?
|
||||
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
|
||||
return a.buildRequestWithCustomURL(apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "")
|
||||
return a.buildRequestWithCustomURL(apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "")
|
||||
}
|
||||
|
||||
// buildRequestWithCustomURL builds an API request with optional custom campaign URL
|
||||
@@ -752,6 +803,7 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
campaignRecipient *model.CampaignRecipient,
|
||||
email *model.Email,
|
||||
customCampaignURL string,
|
||||
oauthAccessToken string,
|
||||
) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) {
|
||||
// create template data first so it can be used in headers, url, and body
|
||||
t := a.TemplateService.CreateMail(
|
||||
@@ -763,6 +815,11 @@ func (a *APISender) buildRequestWithCustomURL(
|
||||
apiSender,
|
||||
)
|
||||
|
||||
// add oauth access token to template data if available
|
||||
if oauthAccessToken != "" {
|
||||
(*t)["OAuthAccessToken"] = oauthAccessToken
|
||||
}
|
||||
|
||||
// override campaign URL if custom one is provided
|
||||
if customCampaignURL != "" {
|
||||
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName, urlPath, urlKey, campaignRecipient.ID.MustGet().String())
|
||||
|
||||
@@ -1692,6 +1692,7 @@ func (c *Campaign) sendCampaignMessages(
|
||||
&repository.CampaignTemplateOption{
|
||||
WithDomain: true,
|
||||
WithSMTPConfiguration: true,
|
||||
WithAPISender: true,
|
||||
WithIdentifier: true,
|
||||
WithBeforeLandingProxy: true,
|
||||
WithLandingProxy: true,
|
||||
@@ -3368,6 +3369,7 @@ func (c *Campaign) sendSingleCampaignMessage(
|
||||
&repository.CampaignTemplateOption{
|
||||
WithDomain: true,
|
||||
WithSMTPConfiguration: true,
|
||||
WithAPISender: true,
|
||||
WithIdentifier: true,
|
||||
WithBeforeLandingProxy: true,
|
||||
WithLandingProxy: true,
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/random"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OAuthProvider service handles oauth provider operations
|
||||
type OAuthProvider struct {
|
||||
Common
|
||||
OAuthProviderRepository *repository.OAuthProvider
|
||||
OAuthStateRepository *repository.OAuthState
|
||||
|
||||
// refreshGroup ensures only one token refresh happens per provider at a time
|
||||
// even if multiple goroutines request simultaneous token refreshes
|
||||
refreshGroup singleflight.Group
|
||||
}
|
||||
|
||||
// TokenResponse represents the response from oauth token endpoints
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// Create creates a new oauth provider
|
||||
func (o *OAuthProvider) Create(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
provider *model.OAuthProvider,
|
||||
) (*uuid.UUID, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.Create", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// validate
|
||||
if err := provider.Validate(); err != nil {
|
||||
o.Logger.Errorw("failed to validate oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := provider.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
|
||||
// check uniqueness
|
||||
name := provider.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(
|
||||
ctx,
|
||||
o.OAuthProviderRepository.DB,
|
||||
"oauth_providers",
|
||||
name.String(),
|
||||
companyID,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
o.Logger.Debugw("oauth provider name is already used", "name", name.String())
|
||||
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
// set initial authorization state
|
||||
provider.IsAuthorized = nullable.NewNullableWithValue(false)
|
||||
|
||||
// save
|
||||
id, err := o.OAuthProviderRepository.Insert(ctx, provider)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to insert oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["id"] = id.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetAll gets all oauth providers with pagination
|
||||
func (o *OAuthProvider) GetAll(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
option repository.OAuthProviderOption,
|
||||
) (*model.Result[model.OAuthProvider], error) {
|
||||
ae := NewAuditEvent("OAuthProvider.GetAll", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get all oauth providers
|
||||
result, err := o.OAuthProviderRepository.GetAll(ctx, companyID, &option)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to get all oauth providers", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// clear sensitive fields before returning
|
||||
for i := range result.Rows {
|
||||
result.Rows[i].ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID gets an oauth provider by id
|
||||
func (o *OAuthProvider) GetByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) (*model.OAuthProvider, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.GetByID", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, *id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
o.Logger.Errorw("failed to get oauth provider by id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// clear sensitive fields
|
||||
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// UpdateByID updates an oauth provider by id
|
||||
func (o *OAuthProvider) UpdateByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
provider *model.OAuthProvider,
|
||||
) error {
|
||||
ae := NewAuditEvent("OAuthProvider.UpdateByID", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// validate
|
||||
if err := provider.Validate(); err != nil {
|
||||
o.Logger.Errorw("failed to validate oauth provider", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// get existing provider
|
||||
existing, err := o.OAuthProviderRepository.GetByID(ctx, *id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := existing.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
|
||||
// check uniqueness
|
||||
name := provider.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(
|
||||
ctx,
|
||||
o.OAuthProviderRepository.DB,
|
||||
"oauth_providers",
|
||||
name.String(),
|
||||
companyID,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
o.Logger.Debugw("oauth provider name is already used", "name", name.String())
|
||||
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
// if client secret is being updated with a non-empty value, invalidate authorization
|
||||
if provider.ClientSecret.IsSpecified() && !provider.ClientSecret.IsNull() {
|
||||
if secret, err := provider.ClientSecret.Get(); err == nil && secret.String() != "" {
|
||||
provider.IsAuthorized = nullable.NewNullableWithValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
// update
|
||||
if err := o.OAuthProviderRepository.UpdateByID(ctx, *id, provider); err != nil {
|
||||
o.Logger.Errorw("failed to update oauth provider", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["id"] = id.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID deletes an oauth provider by id
|
||||
func (o *OAuthProvider) DeleteByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("OAuthProvider.DeleteByID", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// check if provider exists
|
||||
_, err = o.OAuthProviderRepository.GetByID(ctx, *id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// delete
|
||||
if err := o.OAuthProviderRepository.DeleteByID(ctx, *id); err != nil {
|
||||
o.Logger.Errorw("failed to delete oauth provider", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["id"] = id.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAuthorization removes authorization tokens from an oauth provider
|
||||
func (o *OAuthProvider) RemoveAuthorization(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("OAuthProvider.RemoveAuthorization", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// check if provider exists
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, *id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// remove authorization
|
||||
if err := o.OAuthProviderRepository.RemoveAuthorization(ctx, *id); err != nil {
|
||||
o.Logger.Errorw("failed to remove authorization from oauth provider", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
name, _ := provider.Name.Get()
|
||||
ae.Details["id"] = id.String()
|
||||
ae.Details["name"] = name.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAuthorizationURL creates the oauth authorization url for the user to visit
|
||||
func (o *OAuthProvider) GetAuthorizationURL(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
providerID *uuid.UUID,
|
||||
redirectURI string,
|
||||
) (string, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.GetAuthorizationURL", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return "", errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get provider
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, *providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// generate cryptographically random state token (32 bytes base64-encoded)
|
||||
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to generate state token", "error", err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// store state token (expires in 10 minutes)
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
|
||||
// create state token vo
|
||||
stateTokenVO, err := vo.NewString255(stateToken)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to create state token vo", "error", err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
oauthState := &model.OAuthState{
|
||||
StateToken: nullable.NewNullableWithValue(*stateTokenVO),
|
||||
OAuthProviderID: nullable.NewNullableWithValue(*providerID),
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
_, err = o.OAuthStateRepository.Insert(ctx, oauthState)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to store oauth state token", "error", err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// build authorization url
|
||||
authURL := provider.AuthURL.MustGet()
|
||||
clientID := provider.ClientID.MustGet()
|
||||
scopes := provider.Scopes.MustGet()
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {clientID.String()},
|
||||
"redirect_uri": {redirectURI},
|
||||
"response_type": {"code"},
|
||||
"scope": {scopes.String()},
|
||||
"state": {stateToken},
|
||||
"access_type": {"offline"}, // request refresh token
|
||||
"prompt": {"consent"}, // force consent to get refresh token
|
||||
}
|
||||
|
||||
authorizationURL := authURL.String() + "?" + params.Encode()
|
||||
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return authorizationURL, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges authorization code for access and refresh tokens
|
||||
// session can be nil when called from public callback endpoint
|
||||
// security is enforced through state token validation (one-time-use, expires)
|
||||
func (o *OAuthProvider) ExchangeCodeForTokens(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
stateToken string,
|
||||
code string,
|
||||
redirectURI string,
|
||||
) error {
|
||||
// retrieve state token from database
|
||||
oauthState, err := o.OAuthStateRepository.GetByStateToken(ctx, stateToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
o.Logger.Warnw("invalid or expired state token", "stateToken", stateToken)
|
||||
return errors.New("invalid or expired state token")
|
||||
}
|
||||
o.Logger.Errorw("failed to retrieve state token", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// validate state token hasn't been used (prevent replay attacks)
|
||||
if oauthState.Used {
|
||||
o.Logger.Warnw("state token already used", "stateToken", stateToken)
|
||||
return errors.New("state token already used")
|
||||
}
|
||||
|
||||
// validate state token hasn't expired
|
||||
if oauthState.ExpiresAt != nil && time.Now().After(*oauthState.ExpiresAt) {
|
||||
o.Logger.Warnw("state token expired", "stateToken", stateToken, "expiresAt", oauthState.ExpiresAt)
|
||||
return errors.New("state token expired")
|
||||
}
|
||||
|
||||
// get provider from state
|
||||
providerID := oauthState.OAuthProviderID.MustGet()
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// mark state token as used
|
||||
stateID := oauthState.ID.MustGet()
|
||||
if err := o.OAuthStateRepository.MarkAsUsed(ctx, stateID); err != nil {
|
||||
o.Logger.Errorw("failed to mark state token as used", "error", err)
|
||||
// continue anyway - token exchange is more important
|
||||
}
|
||||
|
||||
// get client secret
|
||||
clientSecret := provider.ClientSecret.MustGet().String()
|
||||
|
||||
// exchange code for tokens
|
||||
tokenURL := provider.TokenURL.MustGet()
|
||||
clientID := provider.ClientID.MustGet()
|
||||
|
||||
data := url.Values{
|
||||
"code": {code},
|
||||
"client_id": {clientID.String()},
|
||||
"client_secret": {clientSecret},
|
||||
"redirect_uri": {redirectURI},
|
||||
"grant_type": {"authorization_code"},
|
||||
}
|
||||
|
||||
tokens, err := o.requestTokens(tokenURL.String(), data)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to exchange code for tokens", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// store tokens
|
||||
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||||
if err := o.OAuthProviderRepository.UpdateTokens(
|
||||
ctx,
|
||||
providerID,
|
||||
tokens.AccessToken,
|
||||
tokens.RefreshToken,
|
||||
expiresAt,
|
||||
); err != nil {
|
||||
o.Logger.Errorw("failed to update tokens", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// log successful token exchange
|
||||
o.Logger.Infow("oauth token exchange successful",
|
||||
"providerID", providerID.String(),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValidAccessToken returns a valid access token, refreshing if needed
|
||||
// this is the key method used by other services
|
||||
// uses singleflight to deduplicate concurrent refresh requests for the same provider
|
||||
func (o *OAuthProvider) GetValidAccessToken(
|
||||
ctx context.Context,
|
||||
providerID uuid.UUID,
|
||||
) (string, error) {
|
||||
// use singleflight to ensure only one refresh per provider at a time
|
||||
// key is the provider id - all concurrent calls with same provider will share the same work
|
||||
val, err, shared := o.refreshGroup.Do(providerID.String(), func() (interface{}, error) {
|
||||
return o.getValidAccessTokenInternal(ctx, providerID)
|
||||
})
|
||||
|
||||
if shared {
|
||||
o.Logger.Debugw("oauth token request shared with concurrent call",
|
||||
"providerID", providerID.String(),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return val.(string), nil
|
||||
}
|
||||
|
||||
// getValidAccessTokenInternal is the actual implementation that fetches/refreshes tokens
|
||||
// this is wrapped by GetValidAccessToken with singleflight for concurrency safety
|
||||
func (o *OAuthProvider) getValidAccessTokenInternal(
|
||||
ctx context.Context,
|
||||
providerID uuid.UUID,
|
||||
) (string, error) {
|
||||
// get provider
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check if authorized
|
||||
if provider.IsAuthorized.MustGet() == false {
|
||||
return "", errors.New("oauth provider not authorized - user must complete authorization flow")
|
||||
}
|
||||
|
||||
// check if token needs refresh (5 minute buffer)
|
||||
if provider.TokenExpiresAt != nil && time.Now().Add(5*time.Minute).Before(*provider.TokenExpiresAt) {
|
||||
// token still valid, return as-is
|
||||
accessToken, _ := provider.AccessToken.Get()
|
||||
return accessToken.String(), nil
|
||||
}
|
||||
|
||||
// token expired or about to expire, refresh it
|
||||
o.Logger.Infow("refreshing oauth token", "providerID", providerID.String())
|
||||
|
||||
// get client secret and refresh token (stored as plain text)
|
||||
clientSecret := provider.ClientSecret.MustGet().String()
|
||||
refreshToken, _ := provider.RefreshToken.Get()
|
||||
|
||||
// refresh tokens
|
||||
tokenURL := provider.TokenURL.MustGet()
|
||||
clientID := provider.ClientID.MustGet()
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {clientID.String()},
|
||||
"client_secret": {clientSecret},
|
||||
"refresh_token": {refreshToken.String()},
|
||||
"grant_type": {"refresh_token"},
|
||||
}
|
||||
|
||||
newTokens, err := o.requestTokens(tokenURL.String(), data)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to refresh tokens", "error", err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// some providers return new refresh token, some don't
|
||||
newRefreshToken := newTokens.RefreshToken
|
||||
if newRefreshToken == "" {
|
||||
// keep the old refresh token
|
||||
oldRefresh, _ := provider.RefreshToken.Get()
|
||||
newRefreshToken = oldRefresh.String()
|
||||
}
|
||||
|
||||
// update stored
|
||||
expiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second)
|
||||
if err := o.OAuthProviderRepository.UpdateTokens(
|
||||
ctx,
|
||||
providerID,
|
||||
newTokens.AccessToken,
|
||||
newRefreshToken,
|
||||
expiresAt,
|
||||
); err != nil {
|
||||
o.Logger.Errorw("failed to update refreshed tokens", "error", err)
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
return newTokens.AccessToken, nil
|
||||
}
|
||||
|
||||
// requestTokens makes a request to the token endpoint
|
||||
func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenResponse, error) {
|
||||
resp, err := http.PostForm(tokenURL, data)
|
||||
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 != 200 {
|
||||
return nil, fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokens TokenResponse
|
||||
if err := json.Unmarshal(body, &tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tokens, nil
|
||||
}
|
||||
|
||||
/* @TODO the logic is here, but i dont think we really need to implement it
|
||||
// CleanupExpiredStates removes expired oauth state tokens from database
|
||||
// should be called periodically (e.g., daily)
|
||||
func (o *OAuthProvider) CleanupExpiredStates(ctx context.Context) error {
|
||||
err := o.OAuthStateRepository.DeleteExpired(ctx)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to cleanup expired oauth states", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
**/
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package errgroup provides synchronization, error propagation, and Context
|
||||
// cancelation for groups of goroutines working on subtasks of a common task.
|
||||
// cancellation for groups of goroutines working on subtasks of a common task.
|
||||
//
|
||||
// [errgroup.Group] is related to [sync.WaitGroup] but adds handling of tasks
|
||||
// returning errors.
|
||||
|
||||
Vendored
+2
-1
@@ -571,9 +571,10 @@ golang.org/x/net/ipv4
|
||||
golang.org/x/net/ipv6
|
||||
golang.org/x/net/proxy
|
||||
golang.org/x/net/publicsuffix
|
||||
# golang.org/x/sync v0.17.0
|
||||
# golang.org/x/sync v0.18.0
|
||||
## explicit; go 1.24.0
|
||||
golang.org/x/sync/errgroup
|
||||
golang.org/x/sync/singleflight
|
||||
# golang.org/x/sys v0.37.0
|
||||
## explicit; go 1.24.0
|
||||
golang.org/x/sys/cpu
|
||||
|
||||
Reference in new issue
Block a user