mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-23 19:02:34 +02:00
add import authorized oauth
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -194,6 +194,8 @@ const (
|
||||
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"
|
||||
ROUTE_V1_OAUTH_IMPORT_TOKENS = "/api/v1/oauth-provider/import-tokens"
|
||||
ROUTE_V1_OAUTH_EXPORT_TOKENS = "/api/v1/oauth-provider/:id/export-tokens"
|
||||
// license
|
||||
ROUTE_V1_LICENSE = "/api/v1/license"
|
||||
// version
|
||||
@@ -377,6 +379,8 @@ func setupRoutes(
|
||||
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).
|
||||
POST(ROUTE_V1_OAUTH_IMPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ImportAuthorizedTokens).
|
||||
GET(ROUTE_V1_OAUTH_EXPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ExportAuthorizedTokens).
|
||||
// emails
|
||||
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
|
||||
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
|
||||
|
||||
@@ -372,3 +372,61 @@ func (c *OAuthProvider) renderCallbackPage(g *gin.Context, success bool, errorCo
|
||||
g.Header("Content-Type", "text/html; charset=utf-8")
|
||||
g.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
// ImportAuthorizedTokens imports pre-authorized oauth tokens
|
||||
func (c *OAuthProvider) ImportAuthorizedTokens(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
var req []model.ImportAuthorizedToken
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
// import tokens
|
||||
ids, err := c.OAuthProviderService.ImportAuthorizedTokens(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
req,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// convert ids to strings
|
||||
idStrings := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
idStrings[i] = id.String()
|
||||
}
|
||||
|
||||
c.Response.OK(g, gin.H{
|
||||
"ids": idStrings,
|
||||
"count": len(ids),
|
||||
})
|
||||
}
|
||||
|
||||
// ExportAuthorizedTokens exports oauth tokens in the import format
|
||||
func (c *OAuthProvider) ExportAuthorizedTokens(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// export tokens
|
||||
exported, err := c.OAuthProviderService.ExportAuthorizedTokens(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
*id,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, exported)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ type OAuthProvider struct {
|
||||
// status
|
||||
IsAuthorized bool `gorm:"not null;default:false;"`
|
||||
|
||||
// indicates if this provider was created via import (with pre-authorized tokens)
|
||||
// imported providers cannot be authorized/reauthorized via oauth flow
|
||||
IsImported 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;"`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
)
|
||||
|
||||
// ImportAuthorizedToken represents an imported oauth token
|
||||
type ImportAuthorizedToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt int64 `json:"expires_at"` // unix timestamp in milliseconds
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Scope string `json:"scope"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks if the imported token has a valid state
|
||||
func (i *ImportAuthorizedToken) Validate() error {
|
||||
if i.AccessToken == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "access_token")
|
||||
}
|
||||
if i.RefreshToken == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "refresh_token")
|
||||
}
|
||||
if i.Name == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "name")
|
||||
}
|
||||
if i.ExpiresAt == 0 {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "expires_at")
|
||||
}
|
||||
if i.ClientID == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "client_id")
|
||||
}
|
||||
if i.Scope == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDefaultTokenURL sets the default token url if not provided
|
||||
func (i *ImportAuthorizedToken) SetDefaultTokenURL() {
|
||||
if i.TokenURL == "" {
|
||||
// default to microsoft token url (most common use case)
|
||||
i.TokenURL = "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ type OAuthProvider struct {
|
||||
// status
|
||||
IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed
|
||||
|
||||
// indicates if this provider was created via import (with pre-authorized tokens)
|
||||
// imported providers cannot be authorized/reauthorized via oauth flow
|
||||
IsImported nullable.Nullable[bool] `json:"isImported"`
|
||||
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Company *Company `json:"company"`
|
||||
}
|
||||
@@ -175,5 +179,12 @@ func (o *OAuthProvider) ToDBMap() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
if o.IsImported.IsSpecified() {
|
||||
m["is_imported"] = nil
|
||||
if isImported, err := o.IsImported.Get(); err == nil {
|
||||
m["is_imported"] = isImported
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
|
||||
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
|
||||
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
|
||||
isImported := nullable.NewNullableWithValue(row.IsImported)
|
||||
|
||||
return &model.OAuthProvider{
|
||||
ID: id,
|
||||
@@ -244,6 +245,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
AuthorizedEmail: authorizedEmail,
|
||||
AuthorizedAt: row.AuthorizedAt,
|
||||
IsAuthorized: isAuthorized,
|
||||
IsImported: isImported,
|
||||
Company: nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,20 @@ func (o *OAuthProvider) UpdateByID(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// for imported providers, only allow name updates
|
||||
if existing.IsImported.MustGet() {
|
||||
// clear all fields except name and id
|
||||
provider.AuthURL = nullable.NewNullNullable[vo.String512]()
|
||||
provider.TokenURL = nullable.NewNullNullable[vo.String512]()
|
||||
provider.Scopes = nullable.NewNullNullable[vo.String512]()
|
||||
provider.ClientID = nullable.NewNullNullable[vo.String255]()
|
||||
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
|
||||
provider.AccessToken = nullable.NewNullNullable[vo.OptionalString1MB]()
|
||||
provider.RefreshToken = nullable.NewNullNullable[vo.OptionalString1MB]()
|
||||
provider.IsAuthorized = nullable.NewNullNullable[bool]()
|
||||
provider.IsImported = nullable.NewNullNullable[bool]()
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := existing.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
@@ -366,6 +380,11 @@ func (o *OAuthProvider) GetAuthorizationURL(
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// prevent authorization on imported providers
|
||||
if provider.IsImported.MustGet() {
|
||||
return "", errors.New("cannot authorize imported providers - they use pre-authorized tokens")
|
||||
}
|
||||
|
||||
// generate cryptographically random state token (32 bytes base64-encoded)
|
||||
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
|
||||
if err != nil {
|
||||
@@ -643,6 +662,179 @@ func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenR
|
||||
return &tokens, nil
|
||||
}
|
||||
|
||||
// ImportAuthorizedTokens imports pre-authorized oauth tokens
|
||||
func (o *OAuthProvider) ImportAuthorizedTokens(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
tokens []model.ImportAuthorizedToken,
|
||||
) ([]uuid.UUID, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.ImportAuthorizedTokens", 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 input
|
||||
if len(tokens) == 0 {
|
||||
return nil, errors.New("no tokens provided")
|
||||
}
|
||||
|
||||
var ids []uuid.UUID
|
||||
|
||||
for _, token := range tokens {
|
||||
// validate token
|
||||
if err := token.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set default token url if not provided
|
||||
token.SetDefaultTokenURL()
|
||||
|
||||
// convert expires_at from milliseconds to time
|
||||
expiresAt := time.UnixMilli(token.ExpiresAt)
|
||||
|
||||
// create provider with imported flag
|
||||
provider := &model.OAuthProvider{
|
||||
Name: nullable.NewNullableWithValue(*vo.NewString127Must(token.Name)),
|
||||
AuthURL: nullable.NewNullableWithValue(*vo.NewString512Must("n/a")), // placeholder for imported
|
||||
TokenURL: nullable.NewNullableWithValue(*vo.NewString512Must(token.TokenURL)),
|
||||
Scopes: nullable.NewNullableWithValue(*vo.NewString512Must(token.Scope)),
|
||||
ClientID: nullable.NewNullableWithValue(*vo.NewString255Must(token.ClientID)),
|
||||
ClientSecret: nullable.NewNullableWithValue(*vo.NewOptionalString255Must("n/a")), // placeholder for imported
|
||||
AccessToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.AccessToken)),
|
||||
RefreshToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.RefreshToken)),
|
||||
TokenExpiresAt: &expiresAt,
|
||||
AuthorizedEmail: nullable.NewNullableWithValue(*vo.NewOptionalString255Must(token.User)),
|
||||
AuthorizedAt: ptrTime(time.Now()),
|
||||
IsAuthorized: nullable.NewNullableWithValue(true),
|
||||
IsImported: nullable.NewNullableWithValue(true),
|
||||
CompanyID: nullable.NewNullNullable[uuid.UUID](),
|
||||
}
|
||||
|
||||
// check uniqueness
|
||||
isOK, err := repository.CheckNameIsUnique(
|
||||
ctx,
|
||||
o.OAuthProviderRepository.DB,
|
||||
"oauth_providers",
|
||||
token.Name,
|
||||
nil,
|
||||
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", token.Name)
|
||||
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
// save
|
||||
id, err := o.OAuthProviderRepository.Insert(ctx, provider)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to insert imported oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
ids = append(ids, *id)
|
||||
}
|
||||
|
||||
ae.Details["count"] = len(ids)
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ExportAuthorizedTokens exports oauth tokens in the import format
|
||||
func (o *OAuthProvider) ExportAuthorizedTokens(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
providerID uuid.UUID,
|
||||
) (*model.ImportAuthorizedToken, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.ExportAuthorizedTokens", 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 provider
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
o.Logger.Errorw("failed to get oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check if provider is authorized
|
||||
if !provider.IsAuthorized.MustGet() {
|
||||
return nil, errors.New("provider is not authorized")
|
||||
}
|
||||
|
||||
// extract tokens
|
||||
accessToken := ""
|
||||
if at, err := provider.AccessToken.Get(); err == nil {
|
||||
accessToken = at.String()
|
||||
}
|
||||
|
||||
refreshToken := ""
|
||||
if rt, err := provider.RefreshToken.Get(); err == nil {
|
||||
refreshToken = rt.String()
|
||||
}
|
||||
|
||||
authorizedEmail := ""
|
||||
if ae, err := provider.AuthorizedEmail.Get(); err == nil {
|
||||
authorizedEmail = ae.String()
|
||||
}
|
||||
|
||||
var expiresAt int64
|
||||
if provider.TokenExpiresAt != nil {
|
||||
expiresAt = provider.TokenExpiresAt.UnixMilli()
|
||||
}
|
||||
|
||||
var createdAt int64
|
||||
if provider.CreatedAt != nil {
|
||||
createdAt = provider.CreatedAt.UnixMilli()
|
||||
}
|
||||
|
||||
exported := &model.ImportAuthorizedToken{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ClientID: provider.ClientID.MustGet().String(),
|
||||
ExpiresAt: expiresAt,
|
||||
Name: provider.Name.MustGet().String(),
|
||||
User: authorizedEmail,
|
||||
Scope: provider.Scopes.MustGet().String(),
|
||||
TokenURL: provider.TokenURL.MustGet().String(),
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
ae.Details["id"] = providerID.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return exported, nil
|
||||
}
|
||||
|
||||
// ptrTime returns a pointer to a time.Time
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
/* @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)
|
||||
|
||||
Reference in New Issue
Block a user