1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-30 17:28:18 +02:00
pocketbase/forms/record_oauth2_login.go

268 lines
8.6 KiB
Go

package forms
import (
"errors"
"fmt"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
}
// BeforeOAuth2RecordCreateFunc defines a callback function that will
// be called before OAuth2 new Record creation.
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
// RecordOAuth2Login is an auth record OAuth2 login form.
type RecordOAuth2Login struct {
app core.App
dao *daos.Dao
collection *models.Collection
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
// Optional auth record that will be used if no external
// auth relation is found (if it is from the same collection)
loggedAuthRecord *models.Record
// The name of the OAuth2 client provider (eg. "google")
Provider string `form:"provider" json:"provider"`
// The authorization code returned from the initial request.
Code string `form:"code" json:"code"`
// The code verifier sent with the initial request as part of the code_challenge.
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
// The redirect url sent with the initial request.
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
// Additional data that will be used for creating a new auth record
// if an existing OAuth2 account doesn't exist.
CreateData map[string]any `form:"createData" json:"createData"`
}
// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
// initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
form := &RecordOAuth2Login{
app: app,
dao: app.Dao(),
collection: collection,
loggedAuthRecord: optAuthRecord,
}
return form
}
// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
form.dao = dao
}
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
form.beforeOAuth2RecordCreateFunc = f
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordOAuth2Login) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required),
validation.Field(&form.CodeVerifier, validation.Required),
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
)
}
func (form *RecordOAuth2Login) checkProviderName(value any) error {
name, _ := value.(string)
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
if !ok || !config.Enabled {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
}
return nil
}
// Submit validates and submits the form.
//
// If an auth record doesn't exist, it will make an attempt to create it
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
//
// You can also optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
//
// On success returns the authorized record model and the fetched provider's data.
func (form *RecordOAuth2Login) Submit(
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
) (*models.Record, *auth.AuthUser, error) {
if err := form.Validate(); err != nil {
return nil, nil, err
}
if !form.collection.AuthOptions().AllowOAuth2Auth {
return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
}
provider, err := auth.NewProviderByName(form.Provider)
if err != nil {
return nil, nil, err
}
// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
return nil, nil, err
}
provider.SetRedirectUrl(form.RedirectUrl)
// fetch token
token, err := provider.FetchToken(
form.Code,
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
)
if err != nil {
return nil, nil, err
}
// fetch external auth user
authUser, err := provider.FetchAuthUser(token)
if err != nil {
return nil, nil, err
}
var authRecord *models.Record
// check for existing relation with the auth record
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
switch {
case rel != nil:
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
if err != nil {
return nil, authUser, err
}
case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
// fallback to the logged auth record (if any)
authRecord = form.loggedAuthRecord
case authUser.Email != "":
// look for an existing auth record by the external auth record's email
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
}
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
}
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
return form.submit(newData)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorData.OAuth2User, interceptorsErr
}
return interceptorData.Record, interceptorData.OAuth2User, nil
}
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
if data.Record == nil {
data.Record = models.NewRecord(form.collection)
data.Record.RefreshId()
data.Record.MarkAsNew()
createForm := NewRecordUpsert(form.app, data.Record)
createForm.SetFullManageAccess(true)
createForm.SetDao(txDao)
if data.OAuth2User.Username != "" && usernameRegex.MatchString(data.OAuth2User.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
form.collection.Id,
data.OAuth2User.Username,
)
}
// load custom data
createForm.LoadData(form.CreateData)
// load the OAuth2 profile data as fallback
if createForm.Email == "" {
createForm.Email = data.OAuth2User.Email
}
createForm.Verified = false
if createForm.Email == data.OAuth2User.Email {
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
createForm.Verified = true
}
if createForm.Password == "" {
createForm.Password = security.RandomString(30)
createForm.PasswordConfirm = createForm.Password
}
if form.beforeOAuth2RecordCreateFunc != nil {
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
return err
}
}
// create the new auth record
if err := createForm.Submit(); err != nil {
return err
}
} else {
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
data.Record.SetEmail(data.OAuth2User.Email)
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
data.Record.SetVerified(true)
if err := txDao.SaveRecord(data.Record); err != nil {
return err
}
}
}
// create ExternalAuth relation if missing
if data.ExternalAuth == nil {
data.ExternalAuth = &models.ExternalAuth{
CollectionId: data.Record.Collection().Id,
RecordId: data.Record.Id,
Provider: form.Provider,
ProviderId: data.OAuth2User.Id,
}
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
return err
}
}
return nil
})
}