mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 16:07:18 +02:00
only require needed for oauth import
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -22,7 +22,7 @@ type OAuthProvider struct {
|
||||
// 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);"`
|
||||
Scopes string `gorm:"not null;type:varchar(2048);"`
|
||||
|
||||
// user's oauth app credentials (stored as plain text like smtp passwords)
|
||||
ClientID string `gorm:"not null;type:varchar(255);"`
|
||||
|
||||
@@ -7,37 +7,25 @@ import (
|
||||
|
||||
// ImportAuthorizedToken represents an imported oauth token
|
||||
type ImportAuthorizedToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
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"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"` // unix timestamp in milliseconds
|
||||
Name string `json:"name,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ type OAuthProvider struct {
|
||||
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"`
|
||||
AuthURL nullable.Nullable[vo.String512] `json:"authURL"`
|
||||
TokenURL nullable.Nullable[vo.String512] `json:"tokenURL"`
|
||||
Scopes nullable.Nullable[vo.String2048] `json:"scopes"`
|
||||
|
||||
// user's oauth app credentials
|
||||
ClientID nullable.Nullable[vo.String255] `json:"clientID"`
|
||||
|
||||
@@ -219,7 +219,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
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))
|
||||
scopes := nullable.NewNullableWithValue(*vo.NewString2048Must(row.Scopes))
|
||||
clientID := nullable.NewNullableWithValue(*vo.NewString255Must(row.ClientID))
|
||||
clientSecret := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.ClientSecret))
|
||||
accessToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.AccessToken))
|
||||
|
||||
@@ -217,7 +217,7 @@ func (o *OAuthProvider) UpdateByID(
|
||||
// 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.Scopes = nullable.NewNullNullable[vo.String2048]()
|
||||
provider.ClientID = nullable.NewNullNullable[vo.String255]()
|
||||
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
|
||||
provider.AccessToken = nullable.NewNullNullable[vo.OptionalString1MB]()
|
||||
@@ -603,11 +603,16 @@ func (o *OAuthProvider) getValidAccessTokenInternal(
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {clientID.String()},
|
||||
"client_secret": {clientSecret},
|
||||
"refresh_token": {refreshToken.String()},
|
||||
"grant_type": {"refresh_token"},
|
||||
}
|
||||
|
||||
// only include client_secret if it's not a placeholder (imported tokens use "n/a")
|
||||
// public clients don't need/have client secrets
|
||||
if clientSecret != "" && clientSecret != "n/a" {
|
||||
data.Set("client_secret", clientSecret)
|
||||
}
|
||||
|
||||
newTokens, err := o.requestTokens(tokenURL.String(), data)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to refresh tokens", "error", err)
|
||||
@@ -697,19 +702,66 @@ func (o *OAuthProvider) ImportAuthorizedTokens(
|
||||
// set default token url if not provided
|
||||
token.SetDefaultTokenURL()
|
||||
|
||||
// convert expires_at from milliseconds to time
|
||||
expiresAt := time.UnixMilli(token.ExpiresAt)
|
||||
// generate name if empty
|
||||
if token.Name == "" {
|
||||
randomName, err := random.GenerateRandomURLBase64Encoded(16)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to generate random name for imported token", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
token.Name = fmt.Sprintf("imported-%s", randomName)
|
||||
}
|
||||
|
||||
// refresh token to get fresh access token and metadata
|
||||
// note: don't send client_secret for imported tokens (public clients don't have/need it)
|
||||
tokenURL := token.TokenURL
|
||||
clientID := token.ClientID
|
||||
data := url.Values{
|
||||
"client_id": {clientID},
|
||||
"refresh_token": {token.RefreshToken},
|
||||
"grant_type": {"refresh_token"},
|
||||
}
|
||||
|
||||
o.Logger.Debugw("refreshing token during import", "name", token.Name)
|
||||
newTokens, err := o.requestTokens(tokenURL, data)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to refresh token during import", "error", err, "name", token.Name)
|
||||
return nil, fmt.Errorf("failed to refresh token for '%s': %w", token.Name, err)
|
||||
}
|
||||
|
||||
// use refreshed access token
|
||||
accessToken := newTokens.AccessToken
|
||||
|
||||
// some providers return new refresh token, some don't
|
||||
refreshToken := newTokens.RefreshToken
|
||||
if refreshToken == "" {
|
||||
// keep the original refresh token
|
||||
refreshToken = token.RefreshToken
|
||||
}
|
||||
|
||||
// calculate expiry from refresh response
|
||||
expiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second)
|
||||
|
||||
// use scope from refresh response if available, otherwise use provided scope
|
||||
// if both are empty, use placeholder to satisfy validation
|
||||
scope := newTokens.Scope
|
||||
if scope == "" {
|
||||
scope = token.Scope
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "offline_access" // placeholder scope if none provided
|
||||
}
|
||||
|
||||
// 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)),
|
||||
Scopes: nullable.NewNullableWithValue(*vo.NewString2048Must(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)),
|
||||
AccessToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(accessToken)),
|
||||
RefreshToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(refreshToken)),
|
||||
TokenExpiresAt: &expiresAt,
|
||||
AuthorizedEmail: nullable.NewNullableWithValue(*vo.NewOptionalString255Must(token.User)),
|
||||
AuthorizedAt: ptrTime(time.Now()),
|
||||
|
||||
@@ -552,6 +552,113 @@ func (s OptionalString1024) String() string {
|
||||
return s.inner
|
||||
}
|
||||
|
||||
// String2048 is a trimmed string with a min of 1 and a max of 2048
|
||||
type String2048 struct {
|
||||
inner string
|
||||
}
|
||||
|
||||
// NewString2048 creates a new long string
|
||||
func NewString2048(s string) (*String2048, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
err := validate.ErrorIfStringNotbetweenOrEqualTo(s, 1, 2048)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return &String2048{
|
||||
inner: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewString2048Must creates a new long string and panics if it fails
|
||||
func NewString2048Must(s string) *String2048 {
|
||||
a, err := NewString2048(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface
|
||||
func (s String2048) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.inner)
|
||||
}
|
||||
|
||||
// UnmarshalJSON unmarshals the json into a string
|
||||
func (s *String2048) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
ss, err := NewString2048(str)
|
||||
if err != nil {
|
||||
return unwrapError(err)
|
||||
}
|
||||
s.inner = ss.inner
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the string representation of the long string
|
||||
func (s String2048) String() string {
|
||||
return s.inner
|
||||
}
|
||||
|
||||
// OptionalString2048 is a trimmed string with a min of 0 and a max of 2048
|
||||
type OptionalString2048 struct {
|
||||
inner string
|
||||
}
|
||||
|
||||
// NewEmptyOptionalString2048 creates a new empty string
|
||||
func NewEmptyOptionalString2048() *OptionalString2048 {
|
||||
return &OptionalString2048{
|
||||
inner: "",
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptionalString2048 creates a new long string
|
||||
func NewOptionalString2048(s string) (*OptionalString2048, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
err := validate.ErrorIfStringNotbetweenOrEqualTo(s, 0, 2048)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return &OptionalString2048{
|
||||
inner: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewOptionalString2048Must creates a new long string and panics if it fails
|
||||
func NewOptionalString2048Must(s string) *OptionalString2048 {
|
||||
a, err := NewOptionalString2048(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface
|
||||
func (s OptionalString2048) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.inner)
|
||||
}
|
||||
|
||||
// UnmarshalJSON unmarshals the json into a string
|
||||
func (s *OptionalString2048) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
ss, err := NewOptionalString2048(str)
|
||||
if err != nil {
|
||||
return unwrapError(err)
|
||||
}
|
||||
s.inner = ss.inner
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the string representation of the long string
|
||||
func (s OptionalString2048) String() string {
|
||||
return s.inner
|
||||
}
|
||||
|
||||
// String1MB is a trimmed string with a min of 1 and a max of 1000000
|
||||
type String1MB struct {
|
||||
inner string
|
||||
|
||||
@@ -297,6 +297,20 @@
|
||||
importFormError = '';
|
||||
};
|
||||
|
||||
const onSetImportFile = (event) => {
|
||||
// read file from event
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
importTokensText = /** @type {string} */ (e.target.result);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// reset field
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const onClickImport = async () => {
|
||||
importFormError = '';
|
||||
try {
|
||||
@@ -655,10 +669,36 @@
|
||||
<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.
|
||||
Import a pre-authorized OAuth token. Only <strong>refresh_token</strong> and
|
||||
<strong>client_id</strong> are required. The system will automatically refresh to get a valid
|
||||
access token and populate metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="import-file-input"
|
||||
class="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md font-medium text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
></path>
|
||||
</svg>
|
||||
Load from file
|
||||
</label>
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
on:change={onSetImportFile}
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md mb-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -666,17 +706,11 @@
|
||||
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"
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264"
|
||||
}
|
||||
]`);
|
||||
addToast('Copied format example to clipboard', 'Success');
|
||||
addToast('Copied minimal format example to clipboard', 'Success');
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -704,14 +738,14 @@
|
||||
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"
|
||||
"name": "optional: auto-generated if omitted",
|
||||
"token_url": "optional: defaults to Microsoft",
|
||||
"user": "optional: for display only",
|
||||
"scope": "optional: populated from refresh",
|
||||
"access_token": "optional: refreshed automatically",
|
||||
"expires_at": 0
|
||||
}
|
||||
]`}
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user