authelia/internal/handlers/handler_verify.go
Clément Michaud 9116135401
[BUGFIX] Bad redirection behavior after inactivity and inactivity update events. (#911)
* This affects primarily Authelia instances running behind Traefik or
nginx ingress controllers within Kubernetes because those proxies
require that Authelia returns 302 instead of 401 after the session
has been inactive for too long.
* fixes #909
* fixed activity timestamp not being updated when accessing forbidden resources.
* fix inactivity not updated when user was inactive for too long.
* cover inactivity timeout updates with unit tests.
2020-04-25 09:29:36 +10:00

301 lines
11 KiB
Go

package handlers
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/middlewares"
)
func isURLUnderProtectedDomain(url *url.URL, domain string) bool {
return strings.HasSuffix(url.Hostname(), domain)
}
func isSchemeHTTPS(url *url.URL) bool {
return url.Scheme == "https"
}
func isSchemeWSS(url *url.URL) bool {
return url.Scheme == "wss"
}
// getOriginalURL extract the URL from the request headers (X-Original-URI or X-Forwarded-* headers)
func getOriginalURL(ctx *middlewares.AutheliaCtx) (*url.URL, error) {
originalURL := ctx.XOriginalURL()
if originalURL != nil {
url, err := url.ParseRequestURI(string(originalURL))
if err != nil {
return nil, fmt.Errorf("Unable to parse URL extracted from X-Original-URL header: %v", err)
}
ctx.Logger.Debug("Using X-Original-URL header content as targeted site URL")
return url, nil
}
forwardedProto := ctx.XForwardedProto()
forwardedHost := ctx.XForwardedHost()
forwardedURI := ctx.XForwardedURI()
if forwardedProto == nil {
return nil, errMissingXForwardedProto
}
if forwardedHost == nil {
return nil, errMissingXForwardedHost
}
var requestURI string
scheme := append(forwardedProto, protoHostSeparator...)
requestURI = string(append(scheme,
append(forwardedHost, forwardedURI...)...))
url, err := url.ParseRequestURI(requestURI)
if err != nil {
return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err)
}
ctx.Logger.Debugf("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " +
"to construct targeted site URL")
return url, nil
}
// parseBasicAuth parses an HTTP Basic Authentication string
// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true)
func parseBasicAuth(auth string) (username, password string, err error) {
if !strings.HasPrefix(auth, authPrefix) {
return "", "", fmt.Errorf("%s prefix not found in %s header", strings.Trim(authPrefix, " "), AuthorizationHeader)
}
c, err := base64.StdEncoding.DecodeString(auth[len(authPrefix):])
if err != nil {
return "", "", err
}
cs := string(c)
s := strings.IndexByte(cs, ':')
if s < 0 {
return "", "", fmt.Errorf("Format of %s header must be user:password", AuthorizationHeader)
}
return cs[:s], cs[s+1:], nil
}
// isTargetURLAuthorized check whether the given user is authorized to access the resource
func isTargetURLAuthorized(authorizer *authorization.Authorizer, targetURL url.URL,
username string, userGroups []string, clientIP net.IP, authLevel authentication.Level) authorizationMatching {
level := authorizer.GetRequiredLevel(authorization.Subject{
Username: username,
Groups: userGroups,
IP: clientIP,
}, targetURL)
if level == authorization.Bypass {
return Authorized
} else if username != "" && level == authorization.Denied {
// If the user is not anonymous, it means that we went through
// all the rules related to that user and knowing who he is we can
// deduce the access is forbidden
// For anonymous users though, we cannot be sure that she
// could not be granted the rights to access the resource. Consequently
// for anonymous users we send Unauthorized instead of Forbidden
return Forbidden
} else {
if level == authorization.OneFactor &&
authLevel >= authentication.OneFactor {
return Authorized
} else if level == authorization.TwoFactor &&
authLevel >= authentication.TwoFactor {
return Authorized
}
}
return NotAuthorized
}
// verifyBasicAuth verify that the provided username and password are correct and
// that the user is authorized to target the resource
func verifyBasicAuth(auth []byte, targetURL url.URL, ctx *middlewares.AutheliaCtx) (username string, groups []string, authLevel authentication.Level, err error) { //nolint:unparam
username, password, err := parseBasicAuth(string(auth))
if err != nil {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to parse content of %s header: %s", AuthorizationHeader, err)
}
authenticated, err := ctx.Providers.UserProvider.CheckUserPassword(username, password)
if err != nil {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to check credentials extracted from %s header: %s", AuthorizationHeader, err)
}
// If the user is not correctly authenticated, send a 401
if !authenticated {
// Request Basic Authentication otherwise
return "", nil, authentication.NotAuthenticated, fmt.Errorf("User %s is not authenticated", username)
}
details, err := ctx.Providers.UserProvider.GetDetails(username)
if err != nil {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to retrieve details of user %s: %s", username, err)
}
return username, details.Groups, authentication.OneFactor, nil
}
// setForwardedHeaders set the forwarded User and Groups headers
func setForwardedHeaders(headers *fasthttp.ResponseHeader, username string, groups []string) {
if username != "" {
headers.Set(remoteUserHeader, username)
headers.Set(remoteGroupsHeader, strings.Join(groups, ","))
}
}
// hasUserBeenInactiveLongEnough check whether the user has been inactive for too long
func hasUserBeenInactiveLongEnough(ctx *middlewares.AutheliaCtx) (bool, error) { //nolint:unparam
maxInactivityPeriod := int64(ctx.Providers.SessionProvider.Inactivity.Seconds())
if maxInactivityPeriod == 0 {
return false, nil
}
lastActivity := ctx.GetSession().LastActivity
inactivityPeriod := ctx.Clock.Now().Unix() - lastActivity
ctx.Logger.Tracef("Inactivity report: Inactivity=%d, MaxInactivity=%d",
inactivityPeriod, maxInactivityPeriod)
if inactivityPeriod > maxInactivityPeriod {
return true, nil
}
return false, nil
}
// verifyFromSessionCookie verify if a user identified by a cookie is allowed to access target URL
func verifyFromSessionCookie(targetURL url.URL, ctx *middlewares.AutheliaCtx) (username string, groups []string, authLevel authentication.Level, err error) { //nolint:unparam
userSession := ctx.GetSession()
// No username in the session means the user is anonymous
isUserAnonymous := userSession.Username == ""
if isUserAnonymous && userSession.AuthenticationLevel != authentication.NotAuthenticated {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("An anonymous user cannot be authenticated. That might be the sign of a compromise")
}
if !userSession.KeepMeLoggedIn && !isUserAnonymous {
inactiveLongEnough, err := hasUserBeenInactiveLongEnough(ctx)
if err != nil {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to check if user has been inactive for a long time: %s", err)
}
if inactiveLongEnough {
// Destroy the session a new one will be regenerated on next request
err := ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx)
if err != nil {
return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to destroy user session after long inactivity: %s", err)
}
return userSession.Username, userSession.Groups, authentication.NotAuthenticated, fmt.Errorf("User %s has been inactive for too long", userSession.Username)
}
}
return userSession.Username, userSession.Groups, userSession.AuthenticationLevel, nil
}
func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, username string) {
// Kubernetes ingress controller and Traefik use the rd parameter of the verify
// endpoint to provide the URL of the login portal. The target URL of the user
// is computed from X-Fowarded-* headers or X-Original-URL
rd := string(ctx.QueryArgs().Peek("rd"))
if rd != "" {
redirectionURL := fmt.Sprintf("%s?rd=%s", rd, url.QueryEscape(targetURL.String()))
if strings.Contains(redirectionURL, "/%23/") {
ctx.Logger.Warn("Characters /%23/ have been detected in redirection URL. This is not needed anymore, please strip it")
}
ctx.Logger.Infof("Access to %s is not authorized to user %s, redirecting to %s", targetURL.String(), username, redirectionURL)
ctx.Redirect(redirectionURL, 302)
ctx.SetBodyString(fmt.Sprintf("Found. Redirecting to %s", redirectionURL))
} else {
ctx.Logger.Infof("Access to %s is not authorized to user %s, sending 401 response", targetURL.String(), username)
ctx.ReplyUnauthorized()
}
}
func updateActivityTimestamp(ctx *middlewares.AutheliaCtx, isBasicAuth bool, username string) error {
if isBasicAuth || username == "" {
return nil
}
userSession := ctx.GetSession()
// We don't need to update the activity timestamp when user checked keep me logged in.
if userSession.KeepMeLoggedIn {
return nil
}
// Mark current activity
userSession.LastActivity = ctx.Clock.Now().Unix()
return ctx.SaveSession(userSession)
}
// VerifyGet is the handler verifying if a request is allowed to go through
func VerifyGet(ctx *middlewares.AutheliaCtx) {
ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String())
targetURL, err := getOriginalURL(ctx)
if err != nil {
ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), operationFailedMessage)
return
}
if !isSchemeHTTPS(targetURL) && !isSchemeWSS(targetURL) {
ctx.Logger.Error(fmt.Errorf("Scheme of target URL %s must be secure since cookies are "+
"only transported over a secure connection for security reasons", targetURL.String()))
ctx.ReplyUnauthorized()
return
}
if !isURLUnderProtectedDomain(targetURL, ctx.Configuration.Session.Domain) {
ctx.Logger.Error(fmt.Errorf("The target URL %s is not under the protected domain %s",
targetURL.String(), ctx.Configuration.Session.Domain))
ctx.ReplyUnauthorized()
return
}
var username string
var groups []string
var authLevel authentication.Level
proxyAuthorization := ctx.Request.Header.Peek(AuthorizationHeader)
isBasicAuth := proxyAuthorization != nil
if isBasicAuth {
username, groups, authLevel, err = verifyBasicAuth(proxyAuthorization, *targetURL, ctx)
} else {
username, groups, authLevel, err = verifyFromSessionCookie(*targetURL, ctx)
}
if err != nil {
ctx.Logger.Error(fmt.Sprintf("Error caught when verifying user authorization: %s", err))
if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil {
ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage)
return
}
handleUnauthorized(ctx, targetURL, username)
return
}
authorization := isTargetURLAuthorized(ctx.Providers.Authorizer, *targetURL, username,
groups, ctx.RemoteIP(), authLevel)
if authorization == Forbidden {
ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username)
ctx.ReplyForbidden()
} else if authorization == NotAuthorized {
handleUnauthorized(ctx, targetURL, username)
} else if authorization == Authorized {
setForwardedHeaders(&ctx.Response.Header, username, groups)
}
if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil {
ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage)
}
}