feat: add auth provider service layer and lockout guard
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// ErrLockout is returned when a change would leave an instance with neither
|
||||
// local password login nor an enabled provider — nobody could sign in, and no
|
||||
// endpoint exists to undo it without database access.
|
||||
var ErrLockout = errors.New("that would leave nobody able to sign in")
|
||||
|
||||
// ErrLastProvider is returned when a change would remove or disable the last
|
||||
// enabled provider while local login is also off.
|
||||
var ErrLastProvider = errors.New("cannot remove or disable the last sign-in method")
|
||||
|
||||
// ErrLocalLoginRequired is returned when disabling local login would leave no
|
||||
// enabled provider to sign in with.
|
||||
var ErrLocalLoginRequired = errors.New("local login is required until a provider is enabled")
|
||||
|
||||
const authProviderCol = "auth_providers"
|
||||
|
||||
func authProviderCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// CheckLockout is pure so the two endpoints that can reach this condition —
|
||||
// saving settings and changing a provider — share one answer.
|
||||
func CheckLockout(localEnabled bool, enabledProviders int) error {
|
||||
if localEnabled || enabledProviders > 0 {
|
||||
return nil
|
||||
}
|
||||
return ErrLockout
|
||||
}
|
||||
|
||||
func ListAuthProviders(instanceID string) ([]models.AuthProvider, error) {
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col(authProviderCol).Find(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
options.Find().SetSort(bson.D{{Key: "order", Value: 1}, {Key: "created_at", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.AuthProvider{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListEnabledAuthProviders(instanceID string) ([]models.AuthProvider, error) {
|
||||
all, err := ListAuthProviders(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.AuthProvider{}
|
||||
for _, p := range all {
|
||||
if p.Enabled {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func CountEnabledAuthProviders(instanceID string) (int, error) {
|
||||
enabled, err := ListEnabledAuthProviders(instanceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(enabled), nil
|
||||
}
|
||||
|
||||
// GetAuthProvider is scoped by instance. There is deliberately no lookup by
|
||||
// provider_id alone: a provider ID travels in a URL, and an unscoped lookup
|
||||
// would let one instance's callback resolve another instance's provider.
|
||||
func GetAuthProvider(instanceID, providerID string) (*models.AuthProvider, error) {
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
var p models.AuthProvider
|
||||
err := db.Col(authProviderCol).FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "provider_id": providerID}).Decode(&p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func GetAuthProviderSecret(instanceID, providerID string) (string, error) {
|
||||
p, err := GetAuthProvider(instanceID, providerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.ClientSecretEnc == "" {
|
||||
return "", fmt.Errorf("provider %q has no client secret configured", p.Name)
|
||||
}
|
||||
return decryptString(p.ClientSecretEnc)
|
||||
}
|
||||
|
||||
func CreateAuthProvider(p *models.AuthProvider, clientSecret string) (*models.AuthProvider, error) {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
if strings.TrimSpace(p.ClientID) == "" {
|
||||
return nil, errors.New("client ID is required")
|
||||
}
|
||||
if clientSecret == "" {
|
||||
return nil, errors.New("client secret is required")
|
||||
}
|
||||
enc, err := encryptString(clientSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := randomProviderID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := ListAuthProviders(p.InstanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
p.ProviderID = id
|
||||
p.ClientSecretEnc = enc
|
||||
p.CallbackNotice = false
|
||||
p.Order = len(existing)
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// AuthProviderUpdate carries only what an edit may change. Pointer fields are
|
||||
// "leave alone when nil", which is what lets an empty client secret mean "keep
|
||||
// the stored one" rather than "erase it".
|
||||
type AuthProviderUpdate struct {
|
||||
Name *string
|
||||
Issuer *string
|
||||
ClientID *string
|
||||
ClientSecret *string
|
||||
Scopes *[]string
|
||||
Enabled *bool
|
||||
Order *int
|
||||
}
|
||||
|
||||
func UpdateAuthProvider(instanceID, providerID string, in AuthProviderUpdate) error {
|
||||
set := bson.M{"updated_at": time.Now()}
|
||||
if in.Name != nil {
|
||||
if strings.TrimSpace(*in.Name) == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
set["name"] = *in.Name
|
||||
}
|
||||
if in.Issuer != nil {
|
||||
set["issuer"] = *in.Issuer
|
||||
}
|
||||
if in.ClientID != nil {
|
||||
set["client_id"] = *in.ClientID
|
||||
}
|
||||
if in.Scopes != nil {
|
||||
set["scopes"] = *in.Scopes
|
||||
}
|
||||
if in.Order != nil {
|
||||
set["order"] = *in.Order
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
set["enabled"] = *in.Enabled
|
||||
}
|
||||
// An empty secret means "keep what is stored". Only a non-empty one writes.
|
||||
if in.ClientSecret != nil && *in.ClientSecret != "" {
|
||||
enc, err := encryptString(*in.ClientSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
set["client_secret_enc"] = enc
|
||||
}
|
||||
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col(authProviderCol).UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "provider_id": providerID},
|
||||
bson.M{"$set": set})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return mongo.ErrNoDocuments
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteAuthProvider(instanceID, providerID string) error {
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col(authProviderCol).DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "provider_id": providerID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
return mongo.ErrNoDocuments
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AckAuthProviderNotice(instanceID, providerID string) error {
|
||||
ctx, cancel := authProviderCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col(authProviderCol).UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "provider_id": providerID},
|
||||
bson.M{"$set": bson.M{"callback_notice": false}})
|
||||
return err
|
||||
}
|
||||
|
||||
// randomProviderID is 8 bytes hex: short enough to read in a URL, wide enough
|
||||
// that guessing one is not a way to enumerate an instance's providers.
|
||||
func randomProviderID() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -30,9 +30,11 @@ func EnsureAuthIndexes() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// instance_oidc is control-plane only, so its index stays here.
|
||||
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}},
|
||||
// auth_providers is control-plane only, so its index stays here. The
|
||||
// (instance_id, provider_id) pair is unique because a duplicate provider_id
|
||||
// inside one instance would make the callback ambiguous.
|
||||
if _, err := db.Col("auth_providers").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "provider_id", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user