This commit is contained in:
ItalyPaleAle
2026-07-13 07:13:00 -07:00
parent eac837fc22
commit 1d2f991be5
7 changed files with 239 additions and 112 deletions
@@ -0,0 +1,64 @@
package appconfig
import (
"context"
"errors"
"sync"
"github.com/gin-gonic/gin"
)
// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context
type appConfigCtxKey struct{}
type appConfigResolver func(ctx context.Context) (*AppConfigModel, error)
// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context
type AppConfigMiddleware struct {
appConfigService *AppConfigService
}
func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware {
return &AppConfigMiddleware{
appConfigService: appConfigService,
}
}
// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context
// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request
func (m *AppConfigMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
reqCtx := c.Request.Context()
// Create a cache for each request in the middleware's scope, so it's unique per each request
var (
once sync.Once
cfg *AppConfigModel
err error
)
// Note: the resolver accepts a context argument, it doesn't use the request's own
// This can be used for example for tracing
resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) {
once.Do(func() {
cfg, err = m.appConfigService.GetConfig(ctx)
})
return cfg, err
})
// Store the resolver in the request's context
c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver))
c.Next()
}
}
// FromCtx retrieves the app config from the context
func FromCtx(ctx context.Context) (*AppConfigModel, error) {
resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver)
if !ok || resolver == nil {
// Indicates a development-time error
return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler")
}
return resolver(ctx)
}
+66 -43
View File
@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/italypaleale/go-kit/utils" "github.com/italypaleale/go-kit/utils"
@@ -14,52 +15,74 @@ import (
type AppConfigModel struct { type AppConfigModel struct {
// General // General
AppName string `json:"appName" public:"true"` AppName AppConfigValue `json:"appName" public:"true"`
SessionDuration string `json:"sessionDuration" type:"int"` // In minutes SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
HomePageURL string `json:"homePageUrl" public:"true"` HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
EmailsVerified string `json:"emailsVerified" type:"bool"` EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
AccentColor string `json:"accentColor" public:"true"` AccentColor AppConfigValue `json:"accentColor" public:"true"`
DisableAnimations string `json:"disableAnimations" type:"bool" public:"true"` DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" type:"bool" public:"true"` AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
AllowUserSignups string `json:"allowUserSignups" public:"true"` AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string} SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
// Email // Email
RequireUserEmail string `json:"requireUserEmail" type:"bool" public:"true"` RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
SmtpHost string `json:"smtpHost"` SmtpHost AppConfigValue `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort AppConfigValue `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom"` SmtpFrom AppConfigValue `json:"smtpFrom"`
SmtpUser string `json:"smtpUser"` SmtpUser AppConfigValue `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword" sensitive:"true"` SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
SmtpTls string `json:"smtpTls"` SmtpTls AppConfigValue `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify" type:"bool"` SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" type:"bool"` EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"` EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"` EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" type:"bool"` EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" type:"bool" public:"true"` EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
// LDAP // LDAP
LdapEnabled string `json:"ldapEnabled" type:"bool" public:"true"` LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
LdapUrl string `json:"ldapUrl"` LdapUrl AppConfigValue `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"` LdapBindDn AppConfigValue `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword" sensitive:"true"` LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
LdapBase string `json:"ldapBase"` LdapBase AppConfigValue `json:"ldapBase"`
LdapUserSearchFilter string `json:"ldapUserSearchFilter"` LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify" type:"bool"` LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"` LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
LdapAdminGroupName string `json:"ldapAdminGroupName"` LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" type:"bool"` LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
}
// AppConfigValue holds a value
type AppConfigValue string
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a AppConfigValue) IsTrue() bool {
return utils.IsTruthy(string(a))
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a AppConfigValue) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(string(a))
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
// String implements fmt.Stringer
func (a AppConfigValue) String() string {
return string(a)
} }
func getDefaultConfig() *AppConfigModel { func getDefaultConfig() *AppConfigModel {
-5
View File
@@ -96,11 +96,6 @@ func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel
return &cfg, nil return &cfg, nil
} }
// DELETE
func (s *AppConfigService) GetDbConfig() *model.AppConfig {
return nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error { func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
+10 -2
View File
@@ -1,6 +1,7 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"time" "time"
@@ -509,6 +510,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post] // @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
cfg, err := appconfig.FromCtx(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
loginCode := c.Param("token") loginCode := c.Param("token")
// reject invalid length login codes // reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 { if len(loginCode) != 6 && len(loginCode) != 16 {
@@ -524,12 +531,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token) cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
+39 -34
View File
@@ -36,7 +36,7 @@ type LdapService struct {
userService *UserService userService *UserService
groupService *UserGroupService groupService *UserGroupService
fileStorage storage.FileStorage fileStorage storage.FileStorage
clientFactory func() (ldapClient, error) clientFactory func(ctx context.Context) (ldapClient, error)
} }
type savePicture struct { type savePicture struct {
@@ -84,15 +84,18 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appc
return service return service
} }
func (s *LdapService) createClient() (ldapClient, error) { func (s *LdapService) createClient(ctx context.Context) (ldapClient, error) {
dbConfig := s.appConfigService.GetDbConfig() dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return nil, fmt.Errorf("error loading app configuration: %w", err)
}
if !dbConfig.LdapEnabled.IsTrue() { if !dbConfig.LdapEnabled.IsTrue() {
return nil, fmt.Errorf("LDAP is not enabled") return nil, fmt.Errorf("LDAP is not enabled")
} }
// Setup LDAP connection // Setup LDAP connection
client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{ client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
})) }))
if err != nil { if err != nil {
@@ -100,7 +103,7 @@ func (s *LdapService) createClient() (ldapClient, error) {
} }
// Bind as service account // Bind as service account
err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value) err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err) return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
} }
@@ -196,13 +199,13 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient)
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) { func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
dbConfig := s.appConfigService.GetDbConfig() dbConfig := s.appConfigService.GetDbConfig()
if dbConfig.LdapAdminGroupName.Value == "" { if dbConfig.LdapAdminGroupName == "" {
return return
} }
adminUsernames := make(map[string]struct{}) adminUsernames := make(map[string]struct{})
for _, group := range desiredGroups { for _, group := range desiredGroups {
if group.input.Name != dbConfig.LdapAdminGroupName.Value { if group.input.Name != dbConfig.LdapAdminGroupName {
continue continue
} }
@@ -259,7 +262,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
memberUsernames := make([]string, 0, len(groupMembers)) memberUsernames := make([]string, 0, len(groupMembers))
for _, member := range groupMembers { for _, member := range groupMembers {
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN) username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.Value)
if username == "" { if username == "" {
continue continue
} }
@@ -291,27 +294,30 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
} }
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
dbConfig := s.appConfigService.GetDbConfig() dbConfig, err := appconfig.FromCtx(ctx)
if err != nil {
return nil, nil, nil, fmt.Errorf("error loading app configuration: %w", err)
}
// Query LDAP for all users we want to manage // Query LDAP for all users we want to manage
searchAttrs := []string{ searchAttrs := []string{
"sn", "sn",
"cn", "cn",
dbConfig.LdapAttributeUserUniqueIdentifier.Value, dbConfig.LdapAttributeUserUniqueIdentifier.String(),
dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUsername.String(),
dbConfig.LdapAttributeUserEmail.Value, dbConfig.LdapAttributeUserEmail.String(),
dbConfig.LdapAttributeUserFirstName.Value, dbConfig.LdapAttributeUserFirstName.String(),
dbConfig.LdapAttributeUserLastName.Value, dbConfig.LdapAttributeUserLastName.String(),
dbConfig.LdapAttributeUserProfilePicture.Value, dbConfig.LdapAttributeUserProfilePicture.String(),
dbConfig.LdapAttributeUserDisplayName.Value, dbConfig.LdapAttributeUserDisplayName.String(),
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest( searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value, dbConfig.LdapBase.String(),
ldap.ScopeWholeSubtree, ldap.ScopeWholeSubtree,
0, 0, 0, false, 0, 0, 0, false,
dbConfig.LdapUserSearchFilter.Value, dbConfig.LdapUserSearchFilter.String(),
searchAttrs, searchAttrs,
[]ldap.Control{}, []ldap.Control{},
) )
@@ -327,28 +333,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries)) desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)) username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()))
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" { if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
usernamesByDN[normalizedDN] = username usernamesByDN[normalizedDN] = username
} }
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)) ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String()))
// Skip users without a valid LDAP ID // Skip users without a valid LDAP ID
if ldapID == "" { if ldapID == "" {
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value)) slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String()))
continue continue
} }
ldapUserIDs[ldapID] = struct{}{} ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
EmailVerified: true, EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()),
// Admin status is computed after groups are loaded so it can use the // Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf. // configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false, IsAdmin: false,
@@ -370,16 +376,14 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
desiredUsers = append(desiredUsers, ldapDesiredUser{ desiredUsers = append(desiredUsers, ldapDesiredUser{
ldapID: ldapID, ldapID: ldapID,
input: newUser, input: newUser,
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value), picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
}) })
} }
return desiredUsers, ldapUserIDs, usernamesByDN, nil return desiredUsers, ldapUserIDs, usernamesByDN, nil
} }
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string { func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
dbConfig := s.appConfigService.GetDbConfig()
// First try the DN cache we built while loading users // First try the DN cache we built while loading users
username, exists := usernamesByDN[normalizeLDAPDN(member)] username, exists := usernamesByDN[normalizeLDAPDN(member)]
if exists && username != "" { if exists && username != "" {
@@ -387,14 +391,15 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
} }
// Then try to extract the username directly from the DN // Then try to extract the username directly from the DN
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member) username = getDNProperty(usernameAttr, member)
if username != "" { if username != "" {
return norm.NFC.String(username) return norm.NFC.String(username)
} }
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value // posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408 // that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
if _, err := ldap.ParseDN(member); err != nil { _, err := ldap.ParseDN(member)
if err != nil {
return norm.NFC.String(member) return norm.NFC.String(member)
} }
@@ -404,7 +409,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
ldap.ScopeBaseObject, ldap.ScopeBaseObject,
0, 0, 0, false, 0, 0, 0, false,
"(objectClass=*)", "(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value}, []string{usernameAttr},
[]ldap.Control{}, []ldap.Control{},
) )
@@ -414,7 +419,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
return "" return ""
} }
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value) username = userResult.Entries[0].GetAttributeValue(usernameAttr)
if username == "" { if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member)) slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
return "" return ""
+18 -8
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/appconfig"
@@ -64,13 +65,18 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
} }
func (s *UserGroupService) Delete(ctx context.Context, id string) error { func (s *UserGroupService) Delete(ctx context.Context, id string) error {
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
}() }()
var group model.UserGroup var group model.UserGroup
err := tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("id = ?", id). Where("id = ?", id).
First(&group). First(&group).
@@ -80,7 +86,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
} }
// Disallow deleting the group if it is an LDAP group and LDAP is enabled // Disallow deleting the group if it is an LDAP group and LDAP is enabled
if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return &common.LdapUserGroupUpdateError{} return &common.LdapUserGroupUpdateError{}
} }
@@ -123,10 +129,9 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
Preload("Users"). Preload("Users").
Create(&group). Create(&group).
Error Error
if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) {
if errors.Is(err, gorm.ErrDuplicatedKey) { return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} } else if err != nil {
}
return model.UserGroup{}, err return model.UserGroup{}, err
} }
@@ -162,8 +167,13 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err return model.UserGroup{}, err
} }
cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.UserGroup{}, fmt.Errorf("error loading app configuration: %w", err)
}
// Disallow updating the group if it is an LDAP group and LDAP is enabled // Disallow updating the group if it is an LDAP group and LDAP is enabled
if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { if !isLdapSync && group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{} return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
} }
@@ -217,7 +227,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
// Fetch the users based on the userIds // Fetch the users based on the userIds
var users []model.User var users []model.User
if len(userIds) > 0 { if len(userIds) > 0 {
err := tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("id IN (?)", userIds). Where("id IN (?)", userIds).
Find(&users). Find(&users).
+42 -20
View File
@@ -13,17 +13,17 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
) )
@@ -205,9 +205,13 @@ func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDe
} }
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error { func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error {
var user model.User cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
err := tx. var user model.User
err = tx.
WithContext(ctx). WithContext(ctx).
Where("id = ?", userID). Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}). Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -218,7 +222,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
} }
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { if !allowLdapDelete && !user.Disabled && user.LdapID != nil && cfg.LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
@@ -254,7 +258,12 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
} }
func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil { cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
}
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
return model.User{}, &common.UserEmailNotSetError{} return model.User{}, &common.UserEmailNotSetError{}
} }
@@ -286,7 +295,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
} }
err := tx.WithContext(ctx).Create(&user).Error err = tx.WithContext(ctx).Create(&user).Error
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here // Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync { if !isLdapSync {
@@ -305,12 +314,14 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
// Apply default groups and claims for new non-LDAP users // Apply default groups and claims for new non-LDAP users
if !isLdapSync { if !isLdapSync {
if len(input.UserGroupIds) == 0 { if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { err = s.applyDefaultGroups(ctx, &user, tx)
if err != nil {
return model.User{}, err return model.User{}, err
} }
} }
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { err = s.applyDefaultCustomClaims(ctx, &user, tx)
if err != nil {
return model.User{}, err return model.User{}, err
} }
} }
@@ -323,12 +334,15 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
} }
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error { func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig() cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
var groupIDs []string var groupIDs []string
v := config.SignupDefaultUserGroupIDs.Value v := cfg.SignupDefaultUserGroupIDs
if v != "" && v != "[]" { if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &groupIDs) err = json.Unmarshal([]byte(v), &groupIDs)
if err != nil { if err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err) return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
} }
@@ -355,12 +369,15 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
} }
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig() cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return fmt.Errorf("error loading app configuration: %w", err)
}
var claims []dto.CustomClaimCreateDto var claims []dto.CustomClaimCreateDto
v := config.SignupDefaultCustomClaims.Value v := cfg.SignupDefaultCustomClaims
if v != "" && v != "[]" { if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims) err = json.Unmarshal([]byte(v), &claims)
if err != nil { if err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err) return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
} }
@@ -395,12 +412,17 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser
} }
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil { cfg, err := appconfig.FromCtx(ctx)
if err != nil {
return model.User{}, fmt.Errorf("error loading app configuration: %w", err)
}
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, &common.UserEmailNotSetError{} return model.User{}, &common.UserEmailNotSetError{}
} }
var user model.User var user model.User
err := tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("id = ?", userID). Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}). Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -411,8 +433,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
} }
// Check if this is an LDAP user and LDAP is enabled // Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) { if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
// Restricted update: Only locale can be changed when: // Restricted update: Only locale can be changed when:
@@ -430,7 +452,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) { if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
// Email has changed, reset email verification status // Email has changed, reset email verification status
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() user.EmailVerified = cfg.EmailsVerified.IsTrue()
} }
user.Email = updatedUser.Email user.Email = updatedUser.Email