Files
server/auth/authentication.go
Jannis Mattheis d832b1a41d fix: prevent csrf for cookie requests
Prevously, the token was passed as X-Gotify-Key by the UI, so there was
no csrf because no cookie was added by the browser to the request.

The cookie is saved by SameSite=strict, this provides some protection
against csrf. But an subdomain takeover could still allow for csrf. E.g.
evil.gotify.net could send authenticated requests to gotify.net.

This uses the go builtin cross origin protection, listed on the owasp
page: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#built-in-or-existing-csrf-implementations
2026-07-17 14:09:48 +02:00

312 lines
8.3 KiB
Go

package auth
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/model"
)
type authState int
const (
authStateSkip authState = iota
authStateForbidden
authStateNotElevated
authStateOk
)
const (
headerName = "X-Gotify-Key"
cookieName = "gotify-client-token"
)
var timeNow = time.Now
// The Database interface for encapsulating database access.
type Database interface {
GetApplicationByToken(token string) (*model.Application, error)
GetClientByToken(token string) (*model.Client, error)
GetUserByName(name string) (*model.User, error)
GetUserByID(id uint) (*model.User, error)
UpdateClientTokensLastUsedAndExpiresAt(tokens []string, t *time.Time) error
UpdateApplicationTokenLastUsed(token string, t *time.Time) error
}
// Auth is the provider for authentication middleware.
type Auth struct {
DB Database
SecureCookie bool
CrossOrigin *http.CrossOriginProtection
}
// RequireAdmin requires an elevated client token or basic auth, the user must be an admin.
func (a *Auth) RequireAdmin(ctx *gin.Context) {
a.evaluateOr401(ctx, a.handleUser(a.checkUserAdmin), a.handleClient(a.checkClientAdmin, a.checkClientElevated))
}
// RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied
// with the request.
func (a *Auth) RequireClient(ctx *gin.Context) {
a.evaluateOr401(ctx, a.handleUser(), a.handleClient())
}
// RequireElevatedClient requires an elevated client token or basic auth.
func (a *Auth) RequireElevatedClient(ctx *gin.Context) {
a.evaluateOr401(ctx, a.handleUser(), a.handleClient(a.checkClientElevated))
}
// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request.
func (a *Auth) RequireApplicationToken(ctx *gin.Context) {
if a.evaluate(ctx, a.handleApplication) {
return
}
state, err := a.handleUser()(ctx)
if err != nil {
ctx.AbortWithError(500, err)
}
if state != authStateSkip {
// Return to the user that it's valid authentication, but we don't allow user auth for application endpoints.
a.abort403(ctx)
return
}
a.abort401(ctx)
}
// RequireAny requires client, application, or basic auth.
func (a *Auth) RequireApplicationOrClient(ctx *gin.Context) {
a.evaluateOr401(ctx, a.handleApplication, a.handleClient(), a.handleUser())
}
func (a *Auth) Optional(ctx *gin.Context) {
if !a.evaluate(ctx, a.handleUser(), a.handleClient()) {
ctx.Next()
}
}
func (a *Auth) evaluate(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) bool {
if a.rejectForeignOrigin(ctx) {
return true
}
for _, fn := range funcs {
state, err := fn(ctx)
if err != nil {
if errors.Is(err, errCannotParseToken) {
ctx.AbortWithError(401, err)
return true
}
ctx.AbortWithError(500, err)
return true
}
switch state {
case authStateForbidden:
a.abort403(ctx)
return true
case authStateNotElevated:
ctx.AbortWithError(403, errors.New("session not elevated, use basic auth or call /client:elevate"))
return true
case authStateOk:
ctx.Next()
return true
case authStateSkip:
continue
}
}
return false
}
func (a *Auth) evaluateOr401(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) {
if !a.evaluate(ctx, funcs...) {
a.abort401(ctx)
}
}
func (a *Auth) abort401(ctx *gin.Context) {
ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api"))
}
func (a *Auth) abort403(ctx *gin.Context) {
ctx.AbortWithError(403, errors.New("you are not allowed to access this api"))
}
func (a *Auth) rejectForeignOrigin(ctx *gin.Context) bool {
if _, isCookie := a.readTokenFromRequest(ctx); !isCookie {
return false
}
if err := a.CrossOrigin.Check(ctx.Request); err != nil {
ctx.AbortWithError(403, err)
return true
}
return false
}
func (a *Auth) handleUser(checks ...func(*model.User) (authState, error)) func(ctx *gin.Context) (authState, error) {
return func(ctx *gin.Context) (authState, error) {
if name, pass, ok := ctx.Request.BasicAuth(); ok {
if user, err := a.DB.GetUserByName(name); err != nil {
return authStateSkip, err
} else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) {
RegisterUser(ctx, user)
for _, check := range checks {
if state, err := check(user); err != nil || state != authStateOk {
return state, err
}
}
return authStateOk, nil
}
}
return authStateSkip, nil
}
}
func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) func(ctx *gin.Context) (authState, error) {
return func(ctx *gin.Context) (authState, error) {
token, isCookie := a.readTokenFromRequest(ctx)
originalToken := token
if token == "" {
return authStateSkip, nil
}
if strings.HasPrefix(token, enhancedTokenPrefix) {
complexToken, err := ParseEnhancedToken(token)
if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) {
return authStateSkip, err
}
token = complexToken.PublicForm()
}
client, err := a.DB.GetClientByToken(token)
if err != nil {
return authStateSkip, err
}
if client == nil {
return authStateSkip, nil
}
RegisterClient(ctx, client)
now := timeNow()
if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) {
if err := a.DB.UpdateClientTokensLastUsedAndExpiresAt([]string{client.Token}, &now); err != nil {
return authStateSkip, err
}
if isCookie {
SetCookie(ctx.Writer, originalToken, CookieMaxAge, a.SecureCookie)
}
}
for _, check := range checks {
if state, err := check(client); err != nil || state != authStateOk {
return state, err
}
}
return authStateOk, nil
}
}
func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) {
token, isCookie := a.readTokenFromRequest(ctx)
originalToken := token
if token == "" {
return authStateSkip, nil
}
if strings.HasPrefix(token, enhancedTokenPrefix) {
complexToken, err := ParseEnhancedToken(token)
if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) {
return authStateSkip, err
}
token = complexToken.PublicForm()
}
app, err := a.DB.GetApplicationByToken(token)
if err != nil {
return authStateSkip, err
}
if app == nil {
return authStateSkip, nil
}
RegisterApplication(ctx, app)
now := timeNow()
if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) {
if err := a.DB.UpdateApplicationTokenLastUsed(app.Token, &now); err != nil {
return authStateSkip, err
}
if isCookie {
SetCookie(ctx.Writer, originalToken, CookieMaxAge, a.SecureCookie)
}
}
return authStateOk, nil
}
func (a *Auth) readTokenFromRequest(ctx *gin.Context) (string, bool) {
if token := a.tokenFromQuery(ctx); token != "" {
return token, false
} else if token := a.tokenFromXGotifyHeader(ctx); token != "" {
return token, false
} else if token := a.tokenFromAuthorizationHeader(ctx); token != "" {
return token, false
} else if token := a.tokenFromCookie(ctx); token != "" {
return token, true
}
return "", false
}
func (a *Auth) tokenFromCookie(ctx *gin.Context) string {
token, err := ctx.Cookie(cookieName)
if err != nil {
return ""
}
return token
}
func (a *Auth) tokenFromQuery(ctx *gin.Context) string {
return ctx.Request.URL.Query().Get("token")
}
func (a *Auth) tokenFromXGotifyHeader(ctx *gin.Context) string {
return ctx.Request.Header.Get(headerName)
}
func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string {
const prefix = "Bearer "
authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" {
return ""
}
if len(authHeader) < len(prefix) || !strings.EqualFold(prefix, authHeader[:len(prefix)]) {
return ""
}
return authHeader[len(prefix):]
}
func (a *Auth) checkClientAdmin(client *model.Client) (authState, error) {
if user, err := a.DB.GetUserByID(client.UserID); err != nil {
return authStateSkip, err
} else if !user.Admin {
return authStateForbidden, nil
}
return authStateOk, nil
}
func (a *Auth) checkClientElevated(client *model.Client) (authState, error) {
if client.ElevatedUntil == nil || !timeNow().Before(*client.ElevatedUntil) {
return authStateNotElevated, nil
}
return authStateOk, nil
}
func (a *Auth) checkUserAdmin(user *model.User) (authState, error) {
if !user.Admin {
return authStateForbidden, nil
}
return authStateOk, nil
}