mirror of
https://github.com/datarhei/core.git
synced 2025-10-06 00:17:07 +08:00
469 lines
12 KiB
Go
469 lines
12 KiB
Go
// Package iam implements an identity and access management middleware
|
|
//
|
|
// Four information are required in order to decide to grant access.
|
|
// - identity
|
|
// - domain
|
|
// - resource
|
|
// - action
|
|
//
|
|
// The identity of the requester can be obtained by different means:
|
|
// - JWT
|
|
// - Username and password in the body as JSON
|
|
// - Auth0 access token
|
|
// - Basic auth
|
|
//
|
|
// The path prefix /api/login is treated specially in order to accomodate
|
|
// different ways of identification (UserPass, Auth0). All other /api paths
|
|
// only allow JWT as authentication method.
|
|
//
|
|
// If the identity can't be detected, the identity of "$anon" is given, representing
|
|
// an anonmyous user. If the request originates from localhost and DisableLocalhost
|
|
// is configured, the identity will be $localhost, representing an anonymous user from
|
|
// localhost.
|
|
//
|
|
// The domain is provided as query parameter "group" for all API requests or the
|
|
// first path element after a mountpoint for filesystem requests.
|
|
//
|
|
// If the domain can't be detected, the domain "$none" will be used.
|
|
//
|
|
// The resource is the path of the request. For API requests it's prepended with
|
|
// the "api:" prefix. For all other requests it's prepended with the "fs:" prefix.
|
|
//
|
|
// The action is the requests HTTP method (e.g. GET, POST, ...).
|
|
package iam
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/datarhei/core/v16/http/api"
|
|
"github.com/datarhei/core/v16/http/handler/util"
|
|
"github.com/datarhei/core/v16/iam"
|
|
"github.com/datarhei/core/v16/log"
|
|
|
|
jwtgo "github.com/golang-jwt/jwt/v4"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
)
|
|
|
|
type Config struct {
|
|
// Skipper defines a function to skip middleware.
|
|
Skipper middleware.Skipper
|
|
Mounts []string
|
|
IAM iam.IAM
|
|
DisableLocalhost bool
|
|
WaitAfterFailedLogin bool
|
|
Logger log.Logger
|
|
}
|
|
|
|
var DefaultConfig = Config{
|
|
Skipper: middleware.DefaultSkipper,
|
|
Mounts: []string{},
|
|
IAM: nil,
|
|
DisableLocalhost: false,
|
|
WaitAfterFailedLogin: false,
|
|
Logger: nil,
|
|
}
|
|
|
|
var realm = "datarhei-core"
|
|
|
|
type iammiddleware struct {
|
|
iam iam.IAM
|
|
mounts []string
|
|
logger log.Logger
|
|
}
|
|
|
|
func New() echo.MiddlewareFunc {
|
|
return NewWithConfig(DefaultConfig)
|
|
}
|
|
|
|
func NewWithConfig(config Config) echo.MiddlewareFunc {
|
|
if config.Skipper == nil {
|
|
config.Skipper = DefaultConfig.Skipper
|
|
}
|
|
|
|
if len(config.Mounts) == 0 {
|
|
config.Mounts = append(config.Mounts, "/")
|
|
}
|
|
|
|
if config.Logger == nil {
|
|
config.Logger = log.New("")
|
|
}
|
|
|
|
mw := iammiddleware{
|
|
iam: config.IAM,
|
|
mounts: config.Mounts,
|
|
logger: config.Logger,
|
|
}
|
|
|
|
// Sort the mounts from longest to shortest
|
|
sort.Slice(mw.mounts, func(i, j int) bool {
|
|
return len(mw.mounts[i]) > len(mw.mounts[j])
|
|
})
|
|
|
|
mw.logger.Debug().WithField("mounts", mw.mounts).Log("")
|
|
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
if config.Skipper(c) {
|
|
c.Set("user", "$anon")
|
|
return next(c)
|
|
}
|
|
|
|
if config.IAM == nil {
|
|
return api.Err(http.StatusForbidden, "Forbidden", "IAM is not provided")
|
|
}
|
|
|
|
var identity iam.IdentityVerifier = nil
|
|
var err error
|
|
|
|
username := "$anon"
|
|
resource := c.Request().URL.Path
|
|
var domain string
|
|
|
|
if resource == "/api" || strings.HasPrefix(resource, "/api/") {
|
|
if resource == "/api/login" {
|
|
identity, err = mw.findIdentityFromUserpass(c)
|
|
if err != nil {
|
|
if config.WaitAfterFailedLogin {
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return api.Err(http.StatusForbidden, "Forbidden", "%s", err)
|
|
}
|
|
|
|
if identity == nil {
|
|
identity, err = mw.findIdentityFromAuth0(c)
|
|
if err != nil {
|
|
if config.WaitAfterFailedLogin {
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return api.Err(http.StatusForbidden, "Forbidden", "%s", err)
|
|
}
|
|
}
|
|
} else {
|
|
identity, err = mw.findIdentityFromJWT(c)
|
|
if err != nil {
|
|
return api.Err(http.StatusForbidden, "Forbidden", "%s", err)
|
|
}
|
|
|
|
if identity != nil {
|
|
if resource == "/api/login/refresh" {
|
|
usefor, _ := c.Get("usefor").(string)
|
|
if usefor != "refresh" {
|
|
if config.WaitAfterFailedLogin {
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return api.Err(http.StatusForbidden, "Forbidden", "invalid token")
|
|
}
|
|
} else {
|
|
usefor, _ := c.Get("usefor").(string)
|
|
if usefor != "access" {
|
|
if config.WaitAfterFailedLogin {
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return api.Err(http.StatusForbidden, "Forbidden", "invalid token")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if config.DisableLocalhost {
|
|
ip := c.RealIP()
|
|
if ip == "127.0.0.1" || ip == "::1" {
|
|
username = "$localhost"
|
|
}
|
|
}
|
|
|
|
domain = c.QueryParam("group")
|
|
resource = "api:" + resource
|
|
} else {
|
|
identity, err = mw.findIdentityFromBasicAuth(c)
|
|
if err != nil {
|
|
if err == ErrAuthRequired {
|
|
c.Response().Header().Set(echo.HeaderWWWAuthenticate, "Basic realm="+realm)
|
|
return api.Err(http.StatusUnauthorized, "Unauthorized", "%s", err)
|
|
} else {
|
|
if config.WaitAfterFailedLogin {
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
|
|
if err == ErrBadRequest {
|
|
return api.Err(http.StatusBadRequest, "Bad request", "%s", err)
|
|
} else if err == ErrUnauthorized {
|
|
c.Response().Header().Set(echo.HeaderWWWAuthenticate, "Basic realm="+realm)
|
|
return api.Err(http.StatusUnauthorized, "Unauthorized", "%s", err)
|
|
} else {
|
|
return api.Err(http.StatusForbidden, "Forbidden", "%s", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
domain = mw.findDomainFromFilesystem(resource)
|
|
resource = "fs:" + resource
|
|
}
|
|
|
|
if identity != nil {
|
|
username = identity.Name()
|
|
}
|
|
|
|
c.Set("user", username)
|
|
|
|
if len(domain) == 0 {
|
|
domain = "$none"
|
|
}
|
|
|
|
action := c.Request().Method
|
|
|
|
if !config.IAM.Enforce(username, domain, resource, action) {
|
|
return api.Err(http.StatusForbidden, "Forbidden", "access denied")
|
|
}
|
|
|
|
return next(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
var ErrAuthRequired = errors.New("unauthorized")
|
|
var ErrUnauthorized = errors.New("unauthorized")
|
|
var ErrBadRequest = errors.New("bad request")
|
|
|
|
func (m *iammiddleware) findIdentityFromBasicAuth(c echo.Context) (iam.IdentityVerifier, error) {
|
|
basic := "basic"
|
|
auth := c.Request().Header.Get(echo.HeaderAuthorization)
|
|
l := len(basic)
|
|
|
|
if len(auth) == 0 {
|
|
path := c.Request().URL.Path
|
|
domain := m.findDomainFromFilesystem(path)
|
|
if len(domain) == 0 {
|
|
domain = "$none"
|
|
}
|
|
|
|
if !m.iam.Enforce("$anon", domain, "fs:"+path, c.Request().Method) {
|
|
return nil, ErrAuthRequired
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
var username string
|
|
var password string
|
|
|
|
if len(auth) > l+1 && strings.EqualFold(auth[:l], basic) {
|
|
// Invalid base64 shouldn't be treated as error
|
|
// instead should be treated as invalid client input
|
|
b, err := base64.StdEncoding.DecodeString(auth[l+1:])
|
|
if err != nil {
|
|
return nil, ErrBadRequest
|
|
}
|
|
|
|
cred := string(b)
|
|
for i := 0; i < len(cred); i++ {
|
|
if cred[i] == ':' {
|
|
username, password = cred[:i], cred[i+1:]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
identity, err := m.iam.GetVerifier(username)
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, ErrUnauthorized
|
|
}
|
|
|
|
if ok, err := identity.VerifyServiceBasicAuth(password); !ok {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("wrong password")
|
|
return nil, ErrUnauthorized
|
|
}
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
func (m *iammiddleware) findIdentityFromJWT(c echo.Context) (iam.IdentityVerifier, error) {
|
|
// Look for an Auth header
|
|
values := c.Request().Header.Values("Authorization")
|
|
prefix := "Bearer "
|
|
|
|
auth := ""
|
|
for _, value := range values {
|
|
if !strings.HasPrefix(value, prefix) {
|
|
continue
|
|
}
|
|
|
|
auth = value[len(prefix):]
|
|
|
|
break
|
|
}
|
|
|
|
if len(auth) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
p := &jwtgo.Parser{}
|
|
token, _, err := p.ParseUnverified(auth, jwtgo.MapClaims{})
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, err
|
|
}
|
|
|
|
var subject string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["sub"]; ok {
|
|
subject = sub.(string)
|
|
}
|
|
}
|
|
|
|
var usefor string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["usefor"]; ok {
|
|
usefor = sub.(string)
|
|
}
|
|
}
|
|
|
|
identity, err := m.iam.GetVerifier(subject)
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
if ok, err := identity.VerifyJWT(auth); !ok {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
c.Set("usefor", usefor)
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
func (m *iammiddleware) findIdentityFromUserpass(c echo.Context) (iam.IdentityVerifier, error) {
|
|
var login api.Login
|
|
|
|
if err := util.ShouldBindJSON(c, &login); err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
identity, err := m.iam.GetVerifier(login.Username)
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid username or password")
|
|
}
|
|
|
|
if ok, err := identity.VerifyAPIPassword(login.Password); !ok {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid username or password")
|
|
}
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
func (m *iammiddleware) findIdentityFromAuth0(c echo.Context) (iam.IdentityVerifier, error) {
|
|
// Look for an Auth header
|
|
values := c.Request().Header.Values("Authorization")
|
|
prefix := "Bearer "
|
|
|
|
auth := ""
|
|
for _, value := range values {
|
|
if !strings.HasPrefix(value, prefix) {
|
|
continue
|
|
}
|
|
|
|
auth = value[len(prefix):]
|
|
|
|
break
|
|
}
|
|
|
|
if len(auth) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
p := &jwtgo.Parser{}
|
|
token, _, err := p.ParseUnverified(auth, jwtgo.MapClaims{})
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, nil
|
|
}
|
|
|
|
var subject string
|
|
if claims, ok := token.Claims.(jwtgo.MapClaims); ok {
|
|
if sub, ok := claims["sub"]; ok {
|
|
subject = sub.(string)
|
|
}
|
|
}
|
|
|
|
identity, err := m.iam.GetVerfierFromAuth0(subject)
|
|
if err != nil {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
if ok, err := identity.VerifyAPIAuth0(auth); !ok {
|
|
m.logger.Debug().WithFields(log.Fields{
|
|
"path": c.Request().URL.Path,
|
|
"method": c.Request().Method,
|
|
}).WithError(err).Log("identity not found")
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
return identity, nil
|
|
}
|
|
|
|
func (m *iammiddleware) findDomainFromFilesystem(path string) string {
|
|
path = filepath.Clean(path)
|
|
|
|
// Longest prefix search. The slice is assumed to be sorted accordingly.
|
|
// Assume path is /memfs/foobar/file.txt
|
|
// The longest prefix that matches is /memfs/
|
|
// Remove it from the path and split it into components: foobar file.txt
|
|
// Check if foobar a known domain. If yes, return it. If not, return empty domain.
|
|
for _, mount := range m.mounts {
|
|
prefix := filepath.Clean(mount)
|
|
if prefix != "/" {
|
|
prefix += "/"
|
|
}
|
|
|
|
if strings.HasPrefix(path, prefix) {
|
|
elements := strings.Split(strings.TrimPrefix(path, prefix), "/")
|
|
if m.iam.HasDomain(elements[0]) {
|
|
return elements[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|