mirror of
https://github.com/gotify/server.git
synced 2026-09-06 07:50:35 +08:00
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
312 lines
8.3 KiB
Go
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
|
|
}
|