Adding upstream version 0.28.1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
88f1d47ab6
commit
e28c88ef14
933 changed files with 194711 additions and 0 deletions
374
apis/record_auth_with_oauth2.go
Normal file
374
apis/record_auth_with_oauth2.go
Normal file
|
@ -0,0 +1,374 @@
|
|||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
collection, err := findAuthCollection(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !collection.OAuth2.Enabled {
|
||||
return e.ForbiddenError("The collection is not configured to allow OAuth2 authentication.", nil)
|
||||
}
|
||||
|
||||
var fallbackAuthRecord *core.Record
|
||||
if e.Auth != nil && e.Auth.Collection().Id == collection.Id {
|
||||
fallbackAuthRecord = e.Auth
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
|
||||
|
||||
form := new(recordOAuth2LoginForm)
|
||||
form.collection = collection
|
||||
if err = e.BindBody(form); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
if form.RedirectUrl != "" && form.RedirectURL == "" {
|
||||
e.App.Logger().Warn("[recordAuthWithOAuth2] redirectUrl body param is deprecated and will be removed in the future. Please replace it with redirectURL.")
|
||||
form.RedirectURL = form.RedirectUrl
|
||||
}
|
||||
|
||||
if err = form.validate(); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
// exchange token for OAuth2 user info and locate existing ExternalAuth rel
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load provider configuration
|
||||
providerConfig, ok := collection.OAuth2.GetProviderConfig(form.Provider)
|
||||
if !ok {
|
||||
return e.InternalServerError("Missing or invalid provider config.", nil)
|
||||
}
|
||||
|
||||
provider, err := providerConfig.InitProvider()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.InternalServerError("Failed to init provider "+form.Provider, err))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(e.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider.SetContext(ctx)
|
||||
provider.SetRedirectURL(form.RedirectURL)
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if provider.PKCE() {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
|
||||
}
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(form.Code, opts...)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 token.", err))
|
||||
}
|
||||
|
||||
// fetch external auth user
|
||||
authUser, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
|
||||
}
|
||||
|
||||
var authRecord *core.Record
|
||||
|
||||
// check for existing relation with the auth collection
|
||||
externalAuthRel, err := e.App.FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionRef": form.collection.Id,
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 relation check.", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil && externalAuthRel != nil:
|
||||
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case fallbackAuthRecord != nil && fallbackAuthRecord.Collection().Id == form.collection.Id:
|
||||
// fallback to the logged auth record (if any)
|
||||
authRecord = fallbackAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 auth record check.", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2RequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
event.ProviderName = form.Provider
|
||||
event.ProviderClient = provider
|
||||
event.OAuth2User = authUser
|
||||
event.CreateData = form.CreateData
|
||||
event.Record = authRecord
|
||||
event.IsNewRecord = authRecord == nil
|
||||
|
||||
return e.App.OnRecordAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2RequestEvent) error {
|
||||
if err := oauth2Submit(e, externalAuthRel); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
||||
}
|
||||
|
||||
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
|
||||
meta := map[string]any{}
|
||||
rawOAuth2User, err := json.Marshal(e.OAuth2User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal(rawOAuth2User, &meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta["isNew"] = e.IsNewRecord
|
||||
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type recordOAuth2LoginForm struct {
|
||||
collection *core.Collection
|
||||
|
||||
// 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"`
|
||||
|
||||
// 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 optional PKCE code verifier as part of the code_challenge sent with the initial request.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
RedirectURL string `form:"redirectURL" json:"redirectURL"`
|
||||
|
||||
// @todo
|
||||
// deprecated: use RedirectURL instead
|
||||
// RedirectUrl will be removed after dropping v0.22 support
|
||||
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
|
||||
}
|
||||
|
||||
func (form *recordOAuth2LoginForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.RedirectURL, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
name, _ := value.(string)
|
||||
|
||||
_, ok := form.collection.OAuth2.GetProviderConfig(name)
|
||||
if !ok {
|
||||
return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
|
||||
SetParams(map[string]any{"name": name})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
||||
// ensure that username is unique
|
||||
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
|
||||
if hasUniqueue {
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{"username": username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
|
||||
if exists > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that the value matches the pattern of the username field (if text)
|
||||
txtField, _ := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username).(*core.TextField)
|
||||
|
||||
return txtField != nil && txtField.ValidatePlainValue(username) == nil
|
||||
}
|
||||
|
||||
func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
if e.Record == nil {
|
||||
// extra check to prevent creating a superuser record via
|
||||
// OAuth2 in case the method is used by another action
|
||||
if e.Collection.Name == core.CollectionNameSuperusers {
|
||||
return errors.New("superusers are not allowed to sign-up with OAuth2")
|
||||
}
|
||||
|
||||
payload := maps.Clone(e.CreateData)
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
|
||||
// assign the OAuth2 user email only if the user hasn't submitted one
|
||||
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
|
||||
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
}
|
||||
|
||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
||||
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
|
||||
}
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Name]; !ok && e.Collection.OAuth2.MappedFields.Name != "" {
|
||||
payload[e.Collection.OAuth2.MappedFields.Name] = e.OAuth2User.Name
|
||||
}
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Username]; !ok &&
|
||||
// no explicit username payload value and existing OAuth2 mapping
|
||||
e.Collection.OAuth2.MappedFields.Username != "" &&
|
||||
// extra checks for backward compatibility with earlier versions
|
||||
oldCanAssignUsername(txApp, e.Collection, e.OAuth2User.Username) {
|
||||
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
|
||||
}
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
|
||||
// no explicit avatar payload value and existing OAuth2 mapping
|
||||
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
|
||||
// non-empty OAuth2 avatar url
|
||||
e.OAuth2User.AvatarURL != "" {
|
||||
mappedField := e.Collection.Fields.GetByName(e.Collection.OAuth2.MappedFields.AvatarURL)
|
||||
if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
|
||||
// download the avatar if the mapped field is a file
|
||||
avatarFile, err := func() (*filesystem.File, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return filesystem.NewFileFromURL(ctx, e.OAuth2User.AvatarURL)
|
||||
}()
|
||||
if err != nil {
|
||||
txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
|
||||
} else {
|
||||
payload[e.Collection.OAuth2.MappedFields.AvatarURL] = avatarFile
|
||||
}
|
||||
} else {
|
||||
// otherwise - assign the url string
|
||||
payload[e.Collection.OAuth2.MappedFields.AvatarURL] = e.OAuth2User.AvatarURL
|
||||
}
|
||||
}
|
||||
|
||||
createdRecord, err := sendOAuth2RecordCreateRequest(txApp, e, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.Record = createdRecord
|
||||
|
||||
if e.Record.Email() == e.OAuth2User.Email && !e.Record.Verified() {
|
||||
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
|
||||
e.Record.SetVerified(true)
|
||||
if err := txApp.Save(e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var needUpdate bool
|
||||
|
||||
isLoggedAuthRecord := e.Auth != nil &&
|
||||
e.Auth.Id == e.Record.Id &&
|
||||
e.Auth.Collection().Id == e.Record.Collection().Id
|
||||
|
||||
// set random password for users with unverified email
|
||||
// (this is in case a malicious actor has registered previously with the user email)
|
||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||
e.Record.SetRandomPassword()
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// 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 e.Record.Email() == "" && e.OAuth2User.Email != "" {
|
||||
e.Record.SetEmail(e.OAuth2User.Email)
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// 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 !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
|
||||
e.Record.SetVerified(true)
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
if needUpdate {
|
||||
if err := txApp.Save(e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if optExternalAuth == nil {
|
||||
optExternalAuth = core.NewExternalAuth(txApp)
|
||||
optExternalAuth.SetCollectionRef(e.Record.Collection().Id)
|
||||
optExternalAuth.SetRecordRef(e.Record.Id)
|
||||
optExternalAuth.SetProvider(e.ProviderName)
|
||||
optExternalAuth.SetProviderId(e.OAuth2User.Id)
|
||||
|
||||
if err := txApp.Save(optExternalAuth); err != nil {
|
||||
return fmt.Errorf("failed to save linked rel: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2RequestEvent, payload map[string]any) (*core.Record, error) {
|
||||
ir := &core.InternalRequest{
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/" + e.Collection.Name + "/records",
|
||||
Body: payload,
|
||||
}
|
||||
|
||||
var createdRecord *core.Record
|
||||
response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, func(data any) error {
|
||||
createdRecord, _ = data.(*core.Record)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.Status != http.StatusOK || createdRecord == nil {
|
||||
return nil, errors.New("failed to create OAuth2 auth record")
|
||||
}
|
||||
|
||||
return createdRecord, nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue