1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-02-05 10:45:09 +02:00
pocketbase/core/collection_model_auth_options.go

541 lines
17 KiB
Go

package core
import (
"strconv"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
func (m *Collection) unsetMissingOAuth2MappedFields() {
if !m.IsAuth() {
return
}
if m.OAuth2.MappedFields.Id != "" {
if m.Fields.GetByName(m.OAuth2.MappedFields.Id) == nil {
m.OAuth2.MappedFields.Id = ""
}
}
if m.OAuth2.MappedFields.Name != "" {
if m.Fields.GetByName(m.OAuth2.MappedFields.Name) == nil {
m.OAuth2.MappedFields.Name = ""
}
}
if m.OAuth2.MappedFields.Username != "" {
if m.Fields.GetByName(m.OAuth2.MappedFields.Username) == nil {
m.OAuth2.MappedFields.Username = ""
}
}
if m.OAuth2.MappedFields.AvatarURL != "" {
if m.Fields.GetByName(m.OAuth2.MappedFields.AvatarURL) == nil {
m.OAuth2.MappedFields.AvatarURL = ""
}
}
}
func (m *Collection) setDefaultAuthOptions() {
m.collectionAuthOptions = collectionAuthOptions{
VerificationTemplate: defaultVerificationTemplate,
ResetPasswordTemplate: defaultResetPasswordTemplate,
ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
AuthRule: types.Pointer(""),
AuthAlert: AuthAlertConfig{
Enabled: true,
EmailTemplate: defaultAuthAlertTemplate,
},
PasswordAuth: PasswordAuthConfig{
Enabled: true,
IdentityFields: []string{FieldNameEmail},
},
MFA: MFAConfig{
Enabled: false,
Duration: 1800, // 30min
},
OTP: OTPConfig{
Enabled: false,
Duration: 180, // 3min
Length: 8,
EmailTemplate: defaultOTPTemplate,
},
AuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 604800, // 7 days
},
PasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30min
},
EmailChangeToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30min
},
VerificationToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 259200, // 3days
},
FileToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 180, // 3min
},
}
}
var _ optionsValidator = (*collectionAuthOptions)(nil)
// collectionAuthOptions defines the options for the "auth" type collection.
type collectionAuthOptions struct {
// AuthRule could be used to specify additional record constraints
// applied after record authentication and right before returning the
// auth token response to the client.
//
// For example, to allow only verified users you could set it to
// "verified = true".
//
// Set it to empty string to allow any Auth collection record to authenticate.
//
// Set it to nil to disallow authentication altogether for the collection
// (that includes password, OAuth2, etc.).
AuthRule *string `form:"authRule" json:"authRule"`
// ManageRule gives admin-like permissions to allow fully managing
// the auth record(s), eg. changing the password without requiring
// to enter the old one, directly updating the verified state and email, etc.
//
// This rule is executed in addition to the Create and Update API rules.
ManageRule *string `form:"manageRule" json:"manageRule"`
// AuthAlert defines options related to the auth alerts on new device login.
AuthAlert AuthAlertConfig `form:"authAlert" json:"authAlert"`
// OAuth2 specifies whether OAuth2 auth is enabled for the collection
// and which OAuth2 providers are allowed.
OAuth2 OAuth2Config `form:"oauth2" json:"oauth2"`
PasswordAuth PasswordAuthConfig `form:"passwordAuth" json:"passwordAuth"`
MFA MFAConfig `form:"mfa" json:"mfa"`
OTP OTPConfig `form:"otp" json:"otp"`
// Various token configurations
// ---
AuthToken TokenConfig `form:"authToken" json:"authToken"`
PasswordResetToken TokenConfig `form:"passwordResetToken" json:"passwordResetToken"`
EmailChangeToken TokenConfig `form:"emailChangeToken" json:"emailChangeToken"`
VerificationToken TokenConfig `form:"verificationToken" json:"verificationToken"`
FileToken TokenConfig `form:"fileToken" json:"fileToken"`
// default email templates
// ---
VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"`
ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"`
ConfirmEmailChangeTemplate EmailTemplate `form:"confirmEmailChangeTemplate" json:"confirmEmailChangeTemplate"`
}
func (o *collectionAuthOptions) validate(cv *collectionValidator) error {
err := validation.ValidateStruct(o,
validation.Field(
&o.AuthRule,
validation.By(cv.checkRule),
validation.By(cv.ensureNoSystemRuleChange(cv.original.AuthRule)),
),
validation.Field(
&o.ManageRule,
validation.NilOrNotEmpty,
validation.By(cv.checkRule),
validation.By(cv.ensureNoSystemRuleChange(cv.original.ManageRule)),
),
validation.Field(&o.AuthAlert),
validation.Field(&o.PasswordAuth),
validation.Field(&o.OAuth2),
validation.Field(&o.OTP),
validation.Field(&o.MFA),
validation.Field(&o.AuthToken),
validation.Field(&o.PasswordResetToken),
validation.Field(&o.EmailChangeToken),
validation.Field(&o.VerificationToken),
validation.Field(&o.FileToken),
validation.Field(&o.VerificationTemplate, validation.Required),
validation.Field(&o.ResetPasswordTemplate, validation.Required),
validation.Field(&o.ConfirmEmailChangeTemplate, validation.Required),
)
if err != nil {
return err
}
if o.MFA.Enabled {
// if MFA is enabled require at least 2 auth methods
//
// @todo maybe consider disabling the check because if custom auth methods
// are registered it may fail since we don't have mechanism to detect them at the moment
authsEnabled := 0
if o.PasswordAuth.Enabled {
authsEnabled++
}
if o.OAuth2.Enabled {
authsEnabled++
}
if o.OTP.Enabled {
authsEnabled++
}
if authsEnabled < 2 {
return validation.Errors{
"mfa": validation.Errors{
"enabled": validation.NewError("validation_mfa_not_enough_auths", "MFA requires at least 2 auth methods to be enabled."),
},
}
}
if o.MFA.Rule != "" {
mfaRuleValidators := []validation.RuleFunc{
cv.checkRule,
cv.ensureNoSystemRuleChange(&cv.original.MFA.Rule),
}
for _, validator := range mfaRuleValidators {
err := validator(&o.MFA.Rule)
if err != nil {
return validation.Errors{
"mfa": validation.Errors{
"rule": err,
},
}
}
}
}
}
// extra check to ensure that only unique identity fields are used
if o.PasswordAuth.Enabled {
err = validation.Validate(o.PasswordAuth.IdentityFields, validation.By(cv.checkFieldsForUniqueIndex))
if err != nil {
return validation.Errors{
"passwordAuth": validation.Errors{
"identityFields": err,
},
}
}
}
return nil
}
// -------------------------------------------------------------------
type EmailTemplate struct {
Subject string `form:"subject" json:"subject"`
Body string `form:"body" json:"body"`
}
// Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface.
func (t EmailTemplate) Validate() error {
return validation.ValidateStruct(&t,
validation.Field(&t.Subject, validation.Required),
validation.Field(&t.Body, validation.Required),
)
}
// Resolve replaces the placeholder parameters in the current email
// template and returns its components as ready-to-use strings.
func (t EmailTemplate) Resolve(placeholders map[string]any) (subject, body string) {
body = t.Body
subject = t.Subject
for k, v := range placeholders {
vStr := cast.ToString(v)
// replace subject placeholder params (if any)
subject = strings.ReplaceAll(subject, k, vStr)
// replace body placeholder params (if any)
body = strings.ReplaceAll(body, k, vStr)
}
return subject, body
}
// -------------------------------------------------------------------
type AuthAlertConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"`
}
// Validate makes AuthAlertConfig validatable by implementing [validation.Validatable] interface.
func (c AuthAlertConfig) Validate() error {
return validation.ValidateStruct(&c,
// note: for now always run the email template validations even
// if not enabled since it could be used separately
validation.Field(&c.EmailTemplate),
)
}
// -------------------------------------------------------------------
type TokenConfig struct {
Secret string `form:"secret" json:"secret,omitempty"`
// Duration specifies how long an issued token to be valid (in seconds)
Duration int64 `form:"duration" json:"duration"`
}
// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface.
func (c TokenConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Secret, validation.Required, validation.Length(30, 255)),
validation.Field(&c.Duration, validation.Required, validation.Min(10), validation.Max(94670856)), // ~3y max
)
}
// DurationTime returns the current Duration as [time.Duration].
func (c TokenConfig) DurationTime() time.Duration {
return time.Duration(c.Duration) * time.Second
}
// -------------------------------------------------------------------
type OTPConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
// Duration specifies how long the OTP to be valid (in seconds)
Duration int64 `form:"duration" json:"duration"`
// Length specifies the auto generated password length.
Length int `form:"length" json:"length"`
// EmailTemplate is the default OTP email template that will be send to the auth record.
//
// In addition to the system placeholders you can also make use of
// [core.EmailPlaceholderOTPId] and [core.EmailPlaceholderOTP].
EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"`
}
// Validate makes OTPConfig validatable by implementing [validation.Validatable] interface.
func (c OTPConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))),
validation.Field(&c.Length, validation.When(c.Enabled, validation.Required, validation.Min(4))),
// note: for now always run the email template validations even
// if not enabled since it could be used separately
validation.Field(&c.EmailTemplate),
)
}
// DurationTime returns the current Duration as [time.Duration].
func (c OTPConfig) DurationTime() time.Duration {
return time.Duration(c.Duration) * time.Second
}
// -------------------------------------------------------------------
type MFAConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
// Duration specifies how long an issued MFA to be valid (in seconds)
Duration int64 `form:"duration" json:"duration"`
// Rule is an optional field to restrict MFA only for the records that satisfy the rule.
//
// Leave it empty to enable MFA for everyone.
Rule string `form:"rule" json:"rule"`
}
// Validate makes MFAConfig validatable by implementing [validation.Validatable] interface.
func (c MFAConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))),
)
}
// DurationTime returns the current Duration as [time.Duration].
func (c MFAConfig) DurationTime() time.Duration {
return time.Duration(c.Duration) * time.Second
}
// -------------------------------------------------------------------
type PasswordAuthConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
// IdentityFields is a list of field names that could be used as
// identity during password authentication.
//
// Usually only fields that has single column UNIQUE index are accepted as values.
IdentityFields []string `form:"identityFields" json:"identityFields"`
}
// Validate makes PasswordAuthConfig validatable by implementing [validation.Validatable] interface.
func (c PasswordAuthConfig) Validate() error {
// strip duplicated values
c.IdentityFields = list.ToUniqueStringSlice(c.IdentityFields)
if !c.Enabled {
return nil // no need to validate
}
return validation.ValidateStruct(&c,
validation.Field(&c.IdentityFields, validation.Required),
)
}
// -------------------------------------------------------------------
type OAuth2KnownFields struct {
Id string `form:"id" json:"id"`
Name string `form:"name" json:"name"`
Username string `form:"username" json:"username"`
AvatarURL string `form:"avatarURL" json:"avatarURL"`
}
type OAuth2Config struct {
Providers []OAuth2ProviderConfig `form:"providers" json:"providers"`
MappedFields OAuth2KnownFields `form:"mappedFields" json:"mappedFields"`
Enabled bool `form:"enabled" json:"enabled"`
}
// GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name.
//
// Returns false and zero config if no such provider is available in c.Providers.
func (c OAuth2Config) GetProviderConfig(name string) (config OAuth2ProviderConfig, exists bool) {
for _, p := range c.Providers {
if p.Name == name {
return p, true
}
}
return
}
// Validate makes OAuth2Config validatable by implementing [validation.Validatable] interface.
func (c OAuth2Config) Validate() error {
if !c.Enabled {
return nil // no need to validate
}
return validation.ValidateStruct(&c,
// note: don't require providers for now as they could be externally registered/removed
validation.Field(&c.Providers, validation.By(checkForDuplicatedProviders)),
)
}
func checkForDuplicatedProviders(value any) error {
configs, _ := value.([]OAuth2ProviderConfig)
existing := map[string]struct{}{}
for i, c := range configs {
if c.Name == "" {
continue // the name nonempty state is validated separately
}
if _, ok := existing[c.Name]; ok {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered.").
SetParams(map[string]any{"name": c.Name}),
},
}
}
existing[c.Name] = struct{}{}
}
return nil
}
type OAuth2ProviderConfig struct {
// PKCE overwrites the default provider PKCE config option.
//
// This usually shouldn't be needed but some OAuth2 vendors, like the LinkedIn OIDC,
// may require manual adjustment due to returning error if extra parameters are added to the request
// (https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312)
PKCE *bool `form:"pkce" json:"pkce"`
Name string `form:"name" json:"name"`
ClientId string `form:"clientId" json:"clientId"`
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
AuthURL string `form:"authURL" json:"authURL"`
TokenURL string `form:"tokenURL" json:"tokenURL"`
UserInfoURL string `form:"userInfoURL" json:"userInfoURL"`
DisplayName string `form:"displayName" json:"displayName"`
Extra map[string]any `form:"extra" json:"extra"`
}
// Validate makes OAuth2ProviderConfig validatable by implementing [validation.Validatable] interface.
func (c OAuth2ProviderConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Name, validation.Required, validation.By(checkProviderName)),
validation.Field(&c.ClientId, validation.Required),
validation.Field(&c.ClientSecret, validation.Required),
validation.Field(&c.AuthURL, is.URL),
validation.Field(&c.TokenURL, is.URL),
validation.Field(&c.UserInfoURL, is.URL),
)
}
func checkProviderName(value any) error {
name, _ := value.(string)
if name == "" {
return nil // nothing to check
}
if _, err := auth.NewProviderByName(name); err != nil {
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+".").
SetParams(map[string]any{"name": name})
}
return nil
}
// InitProvider returns a new auth.Provider instance loaded with the current OAuth2ProviderConfig options.
func (c OAuth2ProviderConfig) InitProvider() (auth.Provider, error) {
provider, err := auth.NewProviderByName(c.Name)
if err != nil {
return nil, err
}
if c.ClientId != "" {
provider.SetClientId(c.ClientId)
}
if c.ClientSecret != "" {
provider.SetClientSecret(c.ClientSecret)
}
if c.AuthURL != "" {
provider.SetAuthURL(c.AuthURL)
}
if c.UserInfoURL != "" {
provider.SetUserInfoURL(c.UserInfoURL)
}
if c.TokenURL != "" {
provider.SetTokenURL(c.TokenURL)
}
if c.DisplayName != "" {
provider.SetDisplayName(c.DisplayName)
}
if c.PKCE != nil {
provider.SetPKCE(*c.PKCE)
}
if c.Extra != nil {
provider.SetExtra(c.Extra)
}
return provider, nil
}