Skip to content

Commit 8378d1f

Browse files
authored
Merge branch 'master' into feature/improve-responsive-ui-shell
2 parents 965a5db + 14bfc25 commit 8378d1f

87 files changed

Lines changed: 942 additions & 283 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/application.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
"strings"
1010

1111
"github.com/gin-gonic/gin"
12-
"github.com/gotify/server/v2/auth"
13-
"github.com/gotify/server/v2/model"
12+
"github.com/gotify/server/v3/auth"
13+
"github.com/gotify/server/v3/model"
1414
"github.com/h2non/filetype"
1515
"gorm.io/gorm"
1616
)

api/application_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import (
1313
"testing"
1414

1515
"github.com/gin-gonic/gin"
16-
"github.com/gotify/server/v2/auth"
17-
"github.com/gotify/server/v2/mode"
18-
"github.com/gotify/server/v2/model"
19-
"github.com/gotify/server/v2/test"
20-
"github.com/gotify/server/v2/test/testdb"
16+
"github.com/gotify/server/v3/auth"
17+
"github.com/gotify/server/v3/mode"
18+
"github.com/gotify/server/v3/model"
19+
"github.com/gotify/server/v3/test"
20+
"github.com/gotify/server/v3/test/testdb"
2121
"github.com/stretchr/testify/assert"
2222
"github.com/stretchr/testify/require"
2323
"github.com/stretchr/testify/suite"

api/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"time"
77

88
"github.com/gin-gonic/gin"
9-
"github.com/gotify/server/v2/auth"
10-
"github.com/gotify/server/v2/model"
9+
"github.com/gotify/server/v3/auth"
10+
"github.com/gotify/server/v3/model"
1111
)
1212

1313
// The ClientDatabase interface for encapsulating database access.

api/client_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import (
1111
"time"
1212

1313
"github.com/gin-gonic/gin"
14-
"github.com/gotify/server/v2/auth"
15-
"github.com/gotify/server/v2/mode"
16-
"github.com/gotify/server/v2/model"
17-
"github.com/gotify/server/v2/test"
18-
"github.com/gotify/server/v2/test/testdb"
14+
"github.com/gotify/server/v3/auth"
15+
"github.com/gotify/server/v3/mode"
16+
"github.com/gotify/server/v3/model"
17+
"github.com/gotify/server/v3/test"
18+
"github.com/gotify/server/v3/test/testdb"
1919
"github.com/stretchr/testify/assert"
2020
"github.com/stretchr/testify/suite"
2121
)

api/health.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package api
22

33
import (
44
"github.com/gin-gonic/gin"
5-
"github.com/gotify/server/v2/model"
5+
"github.com/gotify/server/v3/model"
66
)
77

88
// The HealthDatabase interface for encapsulating database access.

api/health_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import (
55
"testing"
66

77
"github.com/gin-gonic/gin"
8-
"github.com/gotify/server/v2/mode"
9-
"github.com/gotify/server/v2/model"
10-
"github.com/gotify/server/v2/test"
11-
"github.com/gotify/server/v2/test/testdb"
8+
"github.com/gotify/server/v3/mode"
9+
"github.com/gotify/server/v3/model"
10+
"github.com/gotify/server/v3/test"
11+
"github.com/gotify/server/v3/test/testdb"
1212
"github.com/stretchr/testify/suite"
1313
)
1414

api/message.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010

1111
"github.com/gin-gonic/gin"
1212
"github.com/gin-gonic/gin/binding"
13-
"github.com/gotify/server/v2/auth"
14-
"github.com/gotify/server/v2/model"
13+
"github.com/gotify/server/v3/auth"
14+
"github.com/gotify/server/v3/model"
1515
)
1616

1717
// The MessageDatabase interface for encapsulating database access.

api/message_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import (
88
"time"
99

1010
"github.com/gin-gonic/gin"
11-
"github.com/gotify/server/v2/auth"
12-
"github.com/gotify/server/v2/mode"
13-
"github.com/gotify/server/v2/model"
14-
"github.com/gotify/server/v2/test"
15-
"github.com/gotify/server/v2/test/testdb"
11+
"github.com/gotify/server/v3/auth"
12+
"github.com/gotify/server/v3/mode"
13+
"github.com/gotify/server/v3/model"
14+
"github.com/gotify/server/v3/test"
15+
"github.com/gotify/server/v3/test/testdb"
1616
"github.com/stretchr/testify/assert"
1717
"github.com/stretchr/testify/suite"
1818
)

api/oidc.go

Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ import (
1010
"log/slog"
1111
"net/http"
1212
"net/url"
13+
"slices"
1314
"strings"
1415
"time"
1516

1617
"github.com/gin-gonic/gin"
17-
"github.com/gotify/server/v2/auth"
18-
"github.com/gotify/server/v2/config"
19-
"github.com/gotify/server/v2/database"
20-
"github.com/gotify/server/v2/decaymap"
21-
"github.com/gotify/server/v2/model"
18+
"github.com/gotify/server/v3/auth"
19+
"github.com/gotify/server/v3/config"
20+
"github.com/gotify/server/v3/database"
21+
"github.com/gotify/server/v3/decaymap"
22+
"github.com/gotify/server/v3/model"
2223
"github.com/rs/zerolog"
2324
"github.com/rs/zerolog/log"
2425
"github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -62,10 +63,14 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
6263
Provider: provider,
6364
UserChangeNotifier: userChangeNotifier,
6465
UsernameClaim: conf.OIDC.UsernameClaim,
66+
GroupsClaim: conf.OIDC.GroupsClaim,
67+
GroupsUser: conf.OIDC.GroupsUser,
68+
GroupsAdmin: conf.OIDC.GroupsAdmin,
6569
PasswordStrength: conf.PassStrength,
6670
SecureCookie: conf.Server.SecureCookie,
6771
AutoRegister: conf.OIDC.AutoRegister,
6872
LinkByUsername: conf.OIDC.LinkByUsername,
73+
Prompt: conf.OIDC.Prompt,
6974
pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge),
7075
}
7176
}
@@ -90,10 +95,14 @@ type OIDCAPI struct {
9095
Provider rp.RelyingParty
9196
UserChangeNotifier *UserChangeNotifier
9297
UsernameClaim string
98+
GroupsClaim string
99+
GroupsUser []string
100+
GroupsAdmin []string
93101
PasswordStrength int
94102
SecureCookie bool
95103
AutoRegister bool
96104
LinkByUsername bool
105+
Prompt []string
97106
pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession]
98107
}
99108

@@ -131,7 +140,7 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
131140
return
132141
}
133142
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{ClientName: clientName, CreatedAt: time.Now()})
134-
rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r)
143+
rp.AuthURLHandler(func() string { return state }, a.Provider, a.promptURLParams()...)(w, r)
135144
})
136145
}
137146

@@ -174,7 +183,14 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
174183
return
175184
}
176185
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{CreatedAt: time.Now(), Elevate: &elevate})
177-
rp.AuthURLHandler(func() string { return state }, a.Provider)(ctx.Writer, ctx.Request)
186+
rp.AuthURLHandler(func() string { return state }, a.Provider, a.promptURLParams()...)(ctx.Writer, ctx.Request)
187+
}
188+
189+
func (a *OIDCAPI) promptURLParams() []rp.URLParamOpt {
190+
if len(a.Prompt) == 0 {
191+
return nil
192+
}
193+
return []rp.URLParamOpt{rp.WithPromptURLParam(a.Prompt...)}
178194
}
179195

180196
// swagger:operation GET /auth/oidc/callback oidc oidcCallback
@@ -207,7 +223,7 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
207223
// $ref: "#/definitions/Error"
208224
func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
209225
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
210-
user, status, err := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
226+
user, status, err := a.resolveUser(tokens.IDTokenClaims, info)
211227
if err != nil {
212228
http.Error(w, err.Error(), status)
213229
return
@@ -315,6 +331,9 @@ func (a *OIDCAPI) ExternalAuthorizeHandler(ctx *gin.Context) {
315331
rp.AuthURLOpt(rp.WithURLParam("redirect_uri", req.RedirectURI)),
316332
rp.WithCodeChallenge(req.CodeChallenge),
317333
}
334+
for _, opt := range a.promptURLParams() {
335+
authOpts = append(authOpts, rp.AuthURLOpt(opt))
336+
}
318337
ctx.JSON(http.StatusOK, &model.OIDCExternalAuthorizeResponse{
319338
AuthorizeURL: rp.AuthURL(state, a.Provider, authOpts...),
320339
State: state,
@@ -373,7 +392,7 @@ func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
373392
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err))
374393
return
375394
}
376-
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
395+
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info)
377396
if resolveErr != nil {
378397
ctx.AbortWithError(status, resolveErr)
379398
return
@@ -404,7 +423,8 @@ func (a *OIDCAPI) generateState() (string, error) {
404423
// this OIDC identity, which requires GOTIFY_OIDC_LINK_BY_USERNAME and
405424
// that the user is not already bound to a different identity.
406425
// 3. Otherwise auto-register a new user, which requires GOTIFY_OIDC_AUTOREGISTER.
407-
func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User, int, error) {
426+
func (a *OIDCAPI) resolveUser(idToken *oidc.IDTokenClaims, info *oidc.UserInfo) (*model.User, int, error) {
427+
issuer := idToken.GetIssuer()
408428
if issuer == "" {
409429
return nil, http.StatusInternalServerError, errors.New("issuer claim was empty")
410430
}
@@ -424,11 +444,25 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
424444
if err != nil {
425445
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
426446
}
447+
448+
hasAdminGroup, status, err := a.resolvePermission(idToken.Claims, info.Claims)
449+
if err != nil {
450+
log.Err(err).Str("oidc_id", oidcID).Interface("idTokenClaims", idToken.Claims).Interface("userinfoClaims", info.Claims).Msg("OIDC: resolve permission")
451+
return nil, status, err
452+
}
453+
427454
if user != nil {
455+
if len(a.GroupsAdmin) > 0 && user.Admin != hasAdminGroup {
456+
user.Admin = hasAdminGroup
457+
if err := a.DB.UpdateUser(user); err != nil {
458+
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
459+
}
460+
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC change permission")
461+
}
428462
return user, 0, nil
429463
}
430464

431-
usernameRaw, ok := info.Claims[a.UsernameClaim]
465+
usernameRaw, ok := lookupClaim(a.UsernameClaim, idToken.Claims, info.Claims)
432466
if !ok {
433467
return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim)
434468
}
@@ -442,12 +476,12 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
442476
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
443477
}
444478
if byUsername != nil {
445-
return a.linkExistingUser(byUsername, oidcID)
479+
return a.linkExistingUser(byUsername, oidcID, hasAdminGroup)
446480
}
447-
return a.registerUser(username, oidcID)
481+
return a.registerUser(username, oidcID, hasAdminGroup)
448482
}
449483

450-
func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User, int, error) {
484+
func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
451485
if !a.LinkByUsername {
452486
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Msgf("OIDC login rejected: a local user with the username already exists and %s is disabled", config.EnvOIDCLinkByUsername)
453487
return nil, http.StatusForbidden, fmt.Errorf("a local user with the username %s already exists and linking by username is disabled", user.Name)
@@ -457,21 +491,34 @@ func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User
457491
return nil, http.StatusForbidden, fmt.Errorf("the user %s is already bound to a different OIDC identity", user.Name)
458492
}
459493
user.OIDCID = &oidcID
494+
if len(a.GroupsAdmin) > 0 {
495+
user.Admin = hasAdminGroup
496+
}
460497
if err := a.DB.UpdateUser(user); err != nil {
461498
return nil, http.StatusInternalServerError, fmt.Errorf("failed to bind user to OIDC identity: %w", err)
462499
}
500+
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC link by username")
463501
return user, 0, nil
464502
}
465503

466-
func (a *OIDCAPI) registerUser(username, oidcID string) (*model.User, int, error) {
504+
func (a *OIDCAPI) registerUser(username, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
467505
if !a.AutoRegister {
468506
return nil, http.StatusForbidden, errors.New("user does not exist and auto-registration is disabled")
469507
}
470-
user := &model.User{Name: username, Admin: false, Pass: nil, OIDCID: &oidcID}
508+
user := &model.User{
509+
Name: username,
510+
Pass: nil,
511+
OIDCID: &oidcID,
512+
}
513+
514+
if len(a.GroupsAdmin) > 0 {
515+
user.Admin = hasAdminGroup
516+
}
517+
471518
if err := a.DB.CreateUser(user); err != nil {
472519
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
473520
}
474-
log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Msg("OIDC auto registration")
521+
log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC auto registration")
475522
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
476523
log.Error().Err(err).Uint("user_id", user.ID).Msg("Could not notify user change")
477524
}
@@ -502,3 +549,59 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
502549
}
503550
return nil, false
504551
}
552+
553+
func (a *OIDCAPI) resolvePermission(idTokenClaims, userInfoClaims map[string]any) (bool, int, error) {
554+
if a.GroupsClaim == "" {
555+
return false, 0, nil
556+
}
557+
558+
groupsRaw, ok := lookupClaim(a.GroupsClaim, idTokenClaims, userInfoClaims)
559+
if !ok {
560+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is missing", a.GroupsClaim)
561+
}
562+
563+
var groups []string
564+
switch groupsRaw := groupsRaw.(type) {
565+
case []string:
566+
groups = groupsRaw
567+
case []any:
568+
for _, groupRaw := range groupsRaw {
569+
group, ok := groupRaw.(string)
570+
if !ok {
571+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q contains a non-string element: %#v", a.GroupsClaim, groupRaw)
572+
}
573+
groups = append(groups, group)
574+
}
575+
case string:
576+
groups = append(groups, groupsRaw)
577+
default:
578+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is not a string or string array: %#v", a.GroupsClaim, groupsRaw)
579+
}
580+
581+
switch {
582+
case containsAny(a.GroupsAdmin, groups):
583+
return true, 0, nil
584+
case len(a.GroupsUser) == 0 || containsAny(a.GroupsUser, groups):
585+
return false, 0, nil
586+
default:
587+
return false, http.StatusForbidden, errors.New("user is not in any allowed group")
588+
}
589+
}
590+
591+
func lookupClaim(name string, idTokenClaims, userInfoClaims map[string]any) (any, bool) {
592+
if value, ok := idTokenClaims[name]; ok {
593+
return value, true
594+
}
595+
value, ok := userInfoClaims[name]
596+
return value, ok
597+
}
598+
599+
func containsAny(configured, actual []string) bool {
600+
for _, value := range actual {
601+
if slices.Contains(configured, value) {
602+
return true
603+
}
604+
}
605+
606+
return false
607+
}

0 commit comments

Comments
 (0)