mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 16:12:44 +00: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_PROVIDER_REMOVE_AUTH = "/api/v1/oauth-provider/:id/remove-authorization"
|
||||||
ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id"
|
ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id"
|
||||||
ROUTE_V1_OAUTH_CALLBACK = "/api/v1/oauth-callback"
|
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
|
// license
|
||||||
ROUTE_V1_LICENSE = "/api/v1/license"
|
ROUTE_V1_LICENSE = "/api/v1/license"
|
||||||
// version
|
// version
|
||||||
@@ -377,6 +379,8 @@ func setupRoutes(
|
|||||||
POST(ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH, middleware.SessionHandler, controllers.OAuthProvider.RemoveAuthorization).
|
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_AUTHORIZE, middleware.SessionHandler, controllers.OAuthProvider.GetAuthorizationURL).
|
||||||
GET(ROUTE_V1_OAUTH_CALLBACK, controllers.OAuthProvider.HandleCallback).
|
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
|
// emails
|
||||||
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
|
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
|
||||||
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
|
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.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
g.String(http.StatusOK, html)
|
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
|
// status
|
||||||
IsAuthorized bool `gorm:"not null;default:false;"`
|
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
|
// can belong-to
|
||||||
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
|
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
|
||||||
Company *Company `gorm:"foreignkey:CompanyID;"`
|
Company *Company `gorm:"foreignkey:CompanyID;"`
|
||||||
|
|||||||
50
backend/model/importAuthorizedToken.go
Normal file
50
backend/model/importAuthorizedToken.go
Normal file
@@ -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
|
// status
|
||||||
IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed
|
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"`
|
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||||
Company *Company `json:"company"`
|
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
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
|||||||
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
|
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
|
||||||
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
|
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
|
||||||
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
|
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
|
||||||
|
isImported := nullable.NewNullableWithValue(row.IsImported)
|
||||||
|
|
||||||
return &model.OAuthProvider{
|
return &model.OAuthProvider{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -244,6 +245,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
|||||||
AuthorizedEmail: authorizedEmail,
|
AuthorizedEmail: authorizedEmail,
|
||||||
AuthorizedAt: row.AuthorizedAt,
|
AuthorizedAt: row.AuthorizedAt,
|
||||||
IsAuthorized: isAuthorized,
|
IsAuthorized: isAuthorized,
|
||||||
|
IsImported: isImported,
|
||||||
Company: nil,
|
Company: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,20 @@ func (o *OAuthProvider) UpdateByID(
|
|||||||
return errs.Wrap(err)
|
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
|
var companyID *uuid.UUID
|
||||||
if cid, err := existing.CompanyID.Get(); err == nil {
|
if cid, err := existing.CompanyID.Get(); err == nil {
|
||||||
companyID = &cid
|
companyID = &cid
|
||||||
@@ -366,6 +380,11 @@ func (o *OAuthProvider) GetAuthorizationURL(
|
|||||||
return "", errs.Wrap(err)
|
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)
|
// generate cryptographically random state token (32 bytes base64-encoded)
|
||||||
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
|
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -643,6 +662,179 @@ func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenR
|
|||||||
return &tokens, nil
|
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
|
/* @TODO the logic is here, but i dont think we really need to implement it
|
||||||
// CleanupExpiredStates removes expired oauth state tokens from database
|
// CleanupExpiredStates removes expired oauth state tokens from database
|
||||||
// should be called periodically (e.g., daily)
|
// should be called periodically (e.g., daily)
|
||||||
|
|||||||
183
examples/oauth-token-import.md
Normal file
183
examples/oauth-token-import.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# OAuth Token Import
|
||||||
|
|
||||||
|
This guide explains how to import existing OAuth access and refresh tokens into PhishingClub.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
You may want to import existing OAuth tokens if you:
|
||||||
|
- Already have valid tokens from a previous OAuth authorization
|
||||||
|
- Want to migrate tokens from another system
|
||||||
|
- Have tokens generated through a different OAuth flow
|
||||||
|
- Need to use tokens that were authorized outside of PhishingClub
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Before importing tokens, you need:
|
||||||
|
1. Valid access and refresh tokens from an OAuth provider
|
||||||
|
2. The token expiration timestamp (in milliseconds)
|
||||||
|
3. The client ID associated with the tokens
|
||||||
|
4. The token URL for refreshing tokens
|
||||||
|
|
||||||
|
## Import Format
|
||||||
|
|
||||||
|
Tokens must be provided as a JSON array. Each token object should have the following fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...",
|
||||||
|
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...",
|
||||||
|
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||||
|
"expires_at": 1765657989704,
|
||||||
|
"name": "user@example.com (Microsoft Teams)",
|
||||||
|
"user": "user@example.com",
|
||||||
|
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||||
|
"token_url": "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token",
|
||||||
|
"created_at": 1765634409156
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Descriptions
|
||||||
|
|
||||||
|
- **access_token** (required): The OAuth access token (typically a JWT)
|
||||||
|
- **refresh_token** (required): The OAuth refresh token used to get new access tokens
|
||||||
|
- **client_id** (required): The OAuth application's client ID
|
||||||
|
- **expires_at** (required): Unix timestamp in milliseconds when the access token expires
|
||||||
|
- **name** (required): A display name for this token (e.g., "user@example.com (Microsoft Teams)")
|
||||||
|
- **user** (required): The email or username of the authorized account
|
||||||
|
- **scope** (required): Space-separated list of OAuth scopes
|
||||||
|
- **token_url** (optional): The token endpoint URL. Defaults to Microsoft's token endpoint if not provided
|
||||||
|
- **created_at** (optional): Unix timestamp in milliseconds when the token was created
|
||||||
|
|
||||||
|
## Importing Tokens via UI
|
||||||
|
|
||||||
|
1. Navigate to the **OAuth** page in PhishingClub
|
||||||
|
2. Click the **Import Authorized Token** button
|
||||||
|
3. Paste your JSON array into the text field
|
||||||
|
4. Click **Submit**
|
||||||
|
|
||||||
|
The system will validate and import all tokens in the array. Each token becomes a separate OAuth provider entry.
|
||||||
|
|
||||||
|
## Importing Tokens via API
|
||||||
|
|
||||||
|
You can also import tokens programmatically using the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/v1/oauth-provider/import-tokens
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...",
|
||||||
|
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...",
|
||||||
|
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||||
|
"expires_at": 1765657989704,
|
||||||
|
"name": "user@example.com (Microsoft Teams)",
|
||||||
|
"user": "user@example.com",
|
||||||
|
"scope": "https://graph.microsoft.com/.default offline_access"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"ids": ["550e8400-e29b-41d4-a716-446655440000"],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exporting Tokens
|
||||||
|
|
||||||
|
You can export tokens from PhishingClub to the same format used for importing:
|
||||||
|
|
||||||
|
1. Navigate to the **OAuth** page
|
||||||
|
2. Find the provider you want to export
|
||||||
|
3. Click the **ellipsis menu (⋮)** on the provider row
|
||||||
|
4. Select **Export Token**
|
||||||
|
|
||||||
|
The token will be downloaded as a JSON file that can be imported elsewhere.
|
||||||
|
|
||||||
|
### Export via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/oauth-provider/{id}/export-tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imported Provider Restrictions
|
||||||
|
|
||||||
|
OAuth providers created via import have some restrictions compared to regular providers:
|
||||||
|
|
||||||
|
1. **Cannot be authorized or re-authorized** - They use pre-authorized tokens and cannot go through the OAuth flow
|
||||||
|
2. **Limited editing** - Only the name field can be edited. Client credentials and URLs cannot be changed
|
||||||
|
3. **No copying** - Imported providers cannot be copied
|
||||||
|
4. **Token refresh only** - They can only refresh their tokens using the refresh token, not obtain new ones via authorization
|
||||||
|
|
||||||
|
## Microsoft 365 / Teams Example
|
||||||
|
|
||||||
|
If you have Microsoft Teams or Office 365 OAuth tokens, they typically look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6ImVCUV9XLUV5d2gtSlpqbXFuWDlUTGF2ZkNIR09kV2U4RFBJM1k5a3FibGciLCJhbGciOiJSUzI1NiIsIng1dCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyIsImtpZCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyJ9...",
|
||||||
|
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW-aP3iO7B_kvK9KqxtUUcw4cmR5AKd5AA.BQABAwEAAAADAOz_BQD0_zaTKAHXsCeupQqQPHKcMtLP8K45KxFiwFgAFG5-NCiSMh30e3jgmHbuuFBYk8qWOaMLqmrh_jfA5biRz6pm7w6zmcD4HbpDCUeQ2eZ0UHAl4aeZbp5FYlSrBgozbdLkgDnSXjKOSeHTVprpbOpe94rzqapKwLvUNjHPQvSiRYyOlh94chh-DTEWSYGUK1EA0bShHa51ZfLZOIeLkeDzqieuSt7b4eqBVvkTLArtAHceN0V9rbLTfMAg18usGY6vdZEwOWwAjayuT-xZPSKdTuqeN6CZ1BHKBj7fhmy48jiXyQolgAL6eXMUnjPC_FsUplJn3guYYXmzTh3B6PHJn6EQjuKWGR9Iaaw4TGB0qmwfJJBfsFNv25EKUR8ragrHE-tTvp9fDuRzTMgW9-_kmngdz95W_ob1RaxN6gsLpZ1O1y75dJkFnoQGLy05YdTAQzo9fdddwHR2NMbe7ovXw440hPqb_ValzBY1ovsMSrr2QSFM1VZDdkWKDyj9JBgzd3XVTWLgcUXpxnou_bM2ZHs2EiRQx9FiFCscTAHg7iFjcMIzA1tFUTunKYtcHN3m6-XGiPUQp2g3Zwu8fEnYo_dG10Ci3uw2PkxmsrVHAT3btlY9QnyGzgUQzfR_Cg9mEmXr476NQE6_NqlBPjZRho2klvolBgAthXtyZPKM1vhL-ei7AcMico9_06DBh-g1uavfQ9LtBLG_RCXfKqU2bTsN0KFp4AhTd49jvGLVPw_bFcQ1DzZHLYiQLv5lZqO6ATZiKJHY9FO3Twpnj7dKxqaxytXlBQ3lmxB5MxjRJumK8lvtlF21-MXn5HYymliU1okLsgHIs-lN-NOVvEMQ2tpt-pJ_XEp8l6baMzjcdILaJ9HkiNPhGoWZ3qkv7k_DKpybkZLX6On9KamfaBP-CKcopBbqvHomuZUItm_6MAdMJBuMFypszop4v_rXq1PC3XYlbDcfwiyHoQen79CgX6xap_31UqeYCaTvazQwFlho-Y3CheTfbDRjRJqfxSPmQDZkZugwRlIICDRlId8GogPqH4a_DCv5N3pbmu-lTNJ0YibCYbEanQxyot_UTQGWCRulUIQ28o8Y5wMn9UoBAzD9opT4g1XW3WnAogU4mdOwO40hhXMraW4Cjd5JQOvj-HUmxlwCHP-PiyhHn9w_WDlqdkigJerw3t12L8wZwC77yipee8JGbZFiIoNYW462wtpkUBkqSfsI2D5O-MgeriY5kYHbCz5e-I5g4pwRj54_mwQUkc61OHkf0rXyiVhH8zsmrCvkWov6gPmf9259_QEZ",
|
||||||
|
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||||
|
"expires_at": 1765657989704,
|
||||||
|
"name": "ronni@365.skansing.dk (Microsoft Teams)",
|
||||||
|
"user": "ronni@365.skansing.dk",
|
||||||
|
"scope": "https://graph.microsoft.com/.default offline_access"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Microsoft OAuth Endpoints
|
||||||
|
|
||||||
|
For Microsoft 365/Teams OAuth:
|
||||||
|
|
||||||
|
**Authorization URL:**
|
||||||
|
```
|
||||||
|
https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token URL:**
|
||||||
|
```
|
||||||
|
https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `{tenant-id}` with your Azure AD tenant ID.
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Invalid Token Format
|
||||||
|
Ensure your JSON is properly formatted and all required fields are present.
|
||||||
|
|
||||||
|
### Expired Tokens
|
||||||
|
The refresh token is invalid or has expired. You need to perform a new OAuth authorization flow.
|
||||||
|
|
||||||
|
### Token Refresh Failures
|
||||||
|
If token refresh fails, check that:
|
||||||
|
- The token URL is correct
|
||||||
|
- The client ID matches the one used to obtain the tokens
|
||||||
|
- The refresh token hasn't been revoked
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Secure Storage**: Imported tokens are stored encrypted in the database, just like regular OAuth tokens.
|
||||||
|
2. **Access Control**: Only users with global permissions can import and export tokens.
|
||||||
|
3. **Token Rotation**: Tokens will be automatically refreshed when they expire using the refresh token.
|
||||||
|
4. **Audit Trail**: All import and export operations are logged in the audit log.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues importing tokens:
|
||||||
|
|
||||||
|
1. Verify the JSON format is correct (use a JSON validator)
|
||||||
|
2. Check that all required fields are present
|
||||||
|
3. Ensure the expires_at timestamp is in milliseconds, not seconds
|
||||||
|
4. Verify the token_url is accessible and correct
|
||||||
|
5. Check the application logs for detailed error messages
|
||||||
@@ -1905,6 +1905,26 @@ export class API {
|
|||||||
*/
|
*/
|
||||||
removeAuthorization: async (id) => {
|
removeAuthorization: async (id) => {
|
||||||
return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {});
|
return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import authorized OAuth tokens.
|
||||||
|
*
|
||||||
|
* @param {Array<{access_token: string, refresh_token: string, client_id: string, expires_at: number, name: string, user: string, scope: string, token_url?: string, created_at?: number}>} tokens
|
||||||
|
* @returns {Promise<ApiResponse>}
|
||||||
|
*/
|
||||||
|
importTokens: async (tokens) => {
|
||||||
|
return await postJSON(this.getPath('/oauth-provider/import-tokens'), tokens);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export OAuth tokens for a provider.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<ApiResponse>}
|
||||||
|
*/
|
||||||
|
exportTokens: async (id) => {
|
||||||
|
return await getJSON(this.getPath(`/oauth-provider/${id}/export-tokens`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
id: null,
|
id: null,
|
||||||
name: null
|
name: null
|
||||||
};
|
};
|
||||||
|
let isImportModalVisible = false;
|
||||||
|
let importTokensText = '';
|
||||||
|
let importFormError = '';
|
||||||
|
let isExportModalVisible = false;
|
||||||
|
let exportTokensText = '';
|
||||||
|
let exportTokenExpiry = '';
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
modalText = getModalText('OAuth', modalMode);
|
modalText = getModalText('OAuth', modalMode);
|
||||||
@@ -279,6 +285,92 @@
|
|||||||
isModalVisible = true;
|
isModalVisible = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openImportModal = () => {
|
||||||
|
importTokensText = '';
|
||||||
|
importFormError = '';
|
||||||
|
isImportModalVisible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImportModal = () => {
|
||||||
|
isImportModalVisible = false;
|
||||||
|
importTokensText = '';
|
||||||
|
importFormError = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickImport = async () => {
|
||||||
|
importFormError = '';
|
||||||
|
try {
|
||||||
|
isSubmitting = true;
|
||||||
|
const tokens = JSON.parse(importTokensText);
|
||||||
|
if (!Array.isArray(tokens)) {
|
||||||
|
importFormError = 'Input must be an array of tokens';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await api.oauthProvider.importTokens(tokens);
|
||||||
|
if (!res.success) {
|
||||||
|
importFormError = res.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToast(`Imported ${res.data.count} OAuth token(s)`, 'Success');
|
||||||
|
closeImportModal();
|
||||||
|
refreshProviders();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
importFormError = 'Invalid JSON format';
|
||||||
|
} else {
|
||||||
|
importFormError = 'Failed to import tokens';
|
||||||
|
console.error('failed to import tokens:', err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickExport = async (id) => {
|
||||||
|
try {
|
||||||
|
showIsLoading();
|
||||||
|
const res = await api.oauthProvider.exportTokens(id);
|
||||||
|
if (res.success) {
|
||||||
|
// format as array for consistency with import format
|
||||||
|
exportTokensText = JSON.stringify([res.data], null, 2);
|
||||||
|
|
||||||
|
// calculate expiry date
|
||||||
|
if (res.data.expires_at) {
|
||||||
|
const expiryDate = new Date(res.data.expires_at);
|
||||||
|
exportTokenExpiry = expiryDate.toLocaleString();
|
||||||
|
} else {
|
||||||
|
exportTokenExpiry = 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
isExportModalVisible = true;
|
||||||
|
} else {
|
||||||
|
throw res.error;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addToast('Failed to export OAuth token', 'Error');
|
||||||
|
console.error('failed to export oauth token', e);
|
||||||
|
} finally {
|
||||||
|
hideIsLoading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeExportModal = () => {
|
||||||
|
isExportModalVisible = false;
|
||||||
|
exportTokensText = '';
|
||||||
|
exportTokenExpiry = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickCopyExport = () => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(exportTokensText)
|
||||||
|
.then(() => {
|
||||||
|
addToast('Copied to clipboard', 'Success');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
addToast('Failed to copy to clipboard', 'Error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openUpdateModal = async (id) => {
|
const openUpdateModal = async (id) => {
|
||||||
modalMode = 'update';
|
modalMode = 'update';
|
||||||
formError = '';
|
formError = '';
|
||||||
@@ -294,7 +386,8 @@
|
|||||||
authURL: provider.authURL,
|
authURL: provider.authURL,
|
||||||
tokenURL: provider.tokenURL,
|
tokenURL: provider.tokenURL,
|
||||||
scopes: provider.scopes,
|
scopes: provider.scopes,
|
||||||
companyID: provider.companyID
|
companyID: provider.companyID,
|
||||||
|
isImported: provider.isImported
|
||||||
};
|
};
|
||||||
isModalVisible = true;
|
isModalVisible = true;
|
||||||
};
|
};
|
||||||
@@ -385,7 +478,10 @@
|
|||||||
<HeadTitle title="OAuth" />
|
<HeadTitle title="OAuth" />
|
||||||
<main>
|
<main>
|
||||||
<Headline>OAuth</Headline>
|
<Headline>OAuth</Headline>
|
||||||
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
<div class="flex gap-2 mb-4">
|
||||||
|
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
||||||
|
<BigButton on:click={openImportModal}>Import Token</BigButton>
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
columns={['Name', 'Status']}
|
columns={['Name', 'Status']}
|
||||||
sortable={['name', 'is_authorized']}
|
sortable={['name', 'is_authorized']}
|
||||||
@@ -420,39 +516,52 @@
|
|||||||
<TableCellEmpty />
|
<TableCellEmpty />
|
||||||
<TableCellAction>
|
<TableCellAction>
|
||||||
<TableDropDownEllipsis>
|
<TableDropDownEllipsis>
|
||||||
<TableCopyButton
|
{#if !provider.isImported}
|
||||||
title="Copy"
|
<TableCopyButton
|
||||||
on:click={() => openCopyModal(provider.id)}
|
title="Copy"
|
||||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
on:click={() => openCopyModal(provider.id)}
|
||||||
/>
|
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<TableUpdateButton
|
<TableUpdateButton
|
||||||
on:click={() => openUpdateModal(provider.id)}
|
on:click={() => openUpdateModal(provider.id)}
|
||||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||||
/>
|
/>
|
||||||
{#if !provider.isAuthorized}
|
{#if provider.isAuthorized}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => onClickAuthorize(provider.id)}
|
on:click={() => onClickExport(provider.id)}
|
||||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<p class="ml-2 text-left">Authorize</p>
|
<p class="ml-2 text-left">Read Token</p>
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={() => onClickAuthorize(provider.id)}
|
|
||||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<p class="ml-2 text-left">Re-authorize</p>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={() => openRemoveAuthAlert(provider)}
|
|
||||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<p class="ml-2 text-left">Remove Authorization</p>
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !provider.isImported}
|
||||||
|
{#if !provider.isAuthorized}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => onClickAuthorize(provider.id)}
|
||||||
|
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<p class="ml-2 text-left">Authorize</p>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => onClickAuthorize(provider.id)}
|
||||||
|
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<p class="ml-2 text-left">Re-authorize</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => openRemoveAuthAlert(provider)}
|
||||||
|
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<p class="ml-2 text-left">Remove Authorization</p>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<TableDeleteButton
|
<TableDeleteButton
|
||||||
on:click={() => openDeleteAlert(provider)}
|
on:click={() => openDeleteAlert(provider)}
|
||||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||||
@@ -476,53 +585,59 @@
|
|||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
</TextField>
|
</TextField>
|
||||||
<TextField
|
{#if modalMode === 'update' && formValues.isImported}
|
||||||
required
|
<p class="text-sm text-gray-600 dark:text-gray-400 italic">
|
||||||
minLength={1}
|
This is an imported provider. Only the name can be edited.
|
||||||
maxLength={255}
|
</p>
|
||||||
bind:value={formValues.clientID}
|
{:else}
|
||||||
placeholder="your-client-id"
|
<TextField
|
||||||
>
|
required
|
||||||
Client ID
|
minLength={1}
|
||||||
</TextField>
|
maxLength={255}
|
||||||
<PasswordField
|
bind:value={formValues.clientID}
|
||||||
required={modalMode === 'create' || modalMode === 'copy'}
|
placeholder="your-client-id"
|
||||||
minLength={modalMode === 'update' ? 0 : 1}
|
>
|
||||||
maxLength={255}
|
Client ID
|
||||||
bind:value={formValues.clientSecret}
|
</TextField>
|
||||||
placeholder={modalMode === 'update'
|
<PasswordField
|
||||||
? 'Leave empty to keep existing secret'
|
required={modalMode === 'create' || modalMode === 'copy'}
|
||||||
: 'your-client-secret'}
|
minLength={modalMode === 'update' ? 0 : 1}
|
||||||
>
|
maxLength={255}
|
||||||
Client Secret
|
bind:value={formValues.clientSecret}
|
||||||
</PasswordField>
|
placeholder={modalMode === 'update'
|
||||||
<TextField
|
? 'Leave empty to keep existing secret'
|
||||||
required
|
: 'your-client-secret'}
|
||||||
minLength={1}
|
>
|
||||||
maxLength={512}
|
Client Secret
|
||||||
bind:value={formValues.authURL}
|
</PasswordField>
|
||||||
placeholder="https://example.com/oauth2/v2/auth"
|
<TextField
|
||||||
>
|
required
|
||||||
Authorization URL
|
minLength={1}
|
||||||
</TextField>
|
maxLength={512}
|
||||||
<TextField
|
bind:value={formValues.authURL}
|
||||||
required
|
placeholder="https://example.com/oauth2/v2/auth"
|
||||||
minLength={1}
|
>
|
||||||
maxLength={512}
|
Authorization URL
|
||||||
bind:value={formValues.tokenURL}
|
</TextField>
|
||||||
placeholder="https://example.com/oauth2/token"
|
<TextField
|
||||||
>
|
required
|
||||||
Token URL
|
minLength={1}
|
||||||
</TextField>
|
maxLength={512}
|
||||||
<TextareaField
|
bind:value={formValues.tokenURL}
|
||||||
required
|
placeholder="https://example.com/oauth2/token"
|
||||||
minLength={1}
|
>
|
||||||
maxLength={512}
|
Token URL
|
||||||
bind:value={formValues.scopes}
|
</TextField>
|
||||||
placeholder="https://example.com/auth/mail.send"
|
<TextareaField
|
||||||
height="small"
|
required
|
||||||
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
|
minLength={1}
|
||||||
>
|
maxLength={512}
|
||||||
|
bind:value={formValues.scopes}
|
||||||
|
placeholder="https://example.com/auth/mail.send"
|
||||||
|
height="small"
|
||||||
|
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</FormColumn>
|
</FormColumn>
|
||||||
</FormColumns>
|
</FormColumns>
|
||||||
|
|
||||||
@@ -531,6 +646,156 @@
|
|||||||
</FormGrid>
|
</FormGrid>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
headerText="Import Token"
|
||||||
|
visible={isImportModalVisible}
|
||||||
|
onClose={closeImportModal}
|
||||||
|
{isSubmitting}
|
||||||
|
>
|
||||||
|
<div class="mt-4 min-w-[800px]">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
Import a pre-authorized OAuth token that was obtained outside of PhishingClub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||||
|
on:click={() => {
|
||||||
|
navigator.clipboard.writeText(`[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOi...",
|
||||||
|
"refresh_token": "1.AXkAwC...",
|
||||||
|
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||||
|
"expires_at": 1765657989704,
|
||||||
|
"name": "user@example.com (Microsoft Teams)",
|
||||||
|
"user": "user@example.com",
|
||||||
|
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||||
|
"token_url": "https://login.microsoftonline.com/.../oauth2/v2.0/token"
|
||||||
|
}
|
||||||
|
]`);
|
||||||
|
addToast('Copied format example to clipboard', 'Success');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Copy format example
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="import-token-textarea"
|
||||||
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Token JSON
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="import-token-textarea"
|
||||||
|
bind:value={importTokensText}
|
||||||
|
required
|
||||||
|
placeholder={`[
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOi...",
|
||||||
|
"refresh_token": "1.AXkAwC...",
|
||||||
|
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||||
|
"expires_at": 1765657989704,
|
||||||
|
"name": "user@example.com (Microsoft Teams)",
|
||||||
|
"user": "user@example.com",
|
||||||
|
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||||
|
"token_url": "https://login.microsoftonline.com/.../oauth2/v2.0/token"
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormGrid on:submit={onClickImport} {isSubmitting}>
|
||||||
|
<FormColumns>
|
||||||
|
<FormColumn>
|
||||||
|
<!-- Empty form column for structure -->
|
||||||
|
</FormColumn>
|
||||||
|
</FormColumns>
|
||||||
|
|
||||||
|
<FormError message={importFormError} />
|
||||||
|
<FormFooter closeModal={closeImportModal} {isSubmitting} />
|
||||||
|
</FormGrid>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal headerText="Export Token" visible={isExportModalVisible} onClose={closeExportModal}>
|
||||||
|
<div class="mt-4 min-w-[800px]">
|
||||||
|
<!-- Expiration Info Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300">Token Information</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
<span class="font-medium">Expires at:</span>
|
||||||
|
<span class="text-pc-darkblue dark:text-white font-semibold ml-2"
|
||||||
|
>{exportTokenExpiry}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token JSON Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label
|
||||||
|
for="export-token-textarea"
|
||||||
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
|
>
|
||||||
|
Token JSON
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="export-token-textarea"
|
||||||
|
readonly
|
||||||
|
value={exportTokensText}
|
||||||
|
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||||
|
on:click={onClickCopyExport}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Copy to clipboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormGrid on:submit={closeExportModal}>
|
||||||
|
<FormColumns>
|
||||||
|
<FormColumn>
|
||||||
|
<!-- Empty form column for structure -->
|
||||||
|
</FormColumn>
|
||||||
|
</FormColumns>
|
||||||
|
<div
|
||||||
|
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-700 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={closeExportModal}
|
||||||
|
class="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormGrid>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<DeleteAlert
|
<DeleteAlert
|
||||||
name={deleteValues.name}
|
name={deleteValues.name}
|
||||||
onClick={() => onClickDelete(deleteValues.id)}
|
onClick={() => onClickDelete(deleteValues.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user