perf(authorizer): preload access control lists (#1640)
* adjust session refresh to always occur (for disabled users) * feat: adds filtering option for Request Method in ACL's * simplify flow of internal/authorization/authorizer.go's methods * implement query string checking * utilize authorizer.Object fully * make matchers uniform * add tests * add missing request methods * add frontend enhancements to handle request method * add request method to 1FA Handler Suite * add internal ACL representations (preparsing) * expand on access_control next * add docs * remove unnecessary slice for network names and instead just use a plain string * add warning for ineffectual bypass policy (due to subjects) * add user/group wildcard support * fix(authorization): allow subject rules to match anonymous users * feat(api): add new params * docs(api): wording adjustments * test: add request method into testing and proxy docs * test: add several checks and refactor schema validation for ACL * test: add integration test for methods acl * refactor: apply suggestions from code review * docs(authorization): update descriptionpull/1780/head
parent
455b859047
commit
4dce8f9496
|
@ -73,14 +73,9 @@ paths:
|
|||
summary: Verification
|
||||
description: The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified domain.
|
||||
parameters:
|
||||
- name: X-Original-URL
|
||||
in: header
|
||||
description: Redirection URL
|
||||
required: true
|
||||
style: simple
|
||||
explode: true
|
||||
schema:
|
||||
type: string
|
||||
- $ref: '#/components/parameters/originalURLParam'
|
||||
- $ref: '#/components/parameters/forwardedMethodParam'
|
||||
- $ref: '#/components/parameters/authParam'
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Operation
|
||||
|
@ -115,14 +110,9 @@ paths:
|
|||
summary: Verification
|
||||
description: The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified domain.
|
||||
parameters:
|
||||
- name: X-Original-URL
|
||||
in: header
|
||||
description: Redirection URL
|
||||
required: true
|
||||
style: simple
|
||||
explode: true
|
||||
schema:
|
||||
type: string
|
||||
- $ref: '#/components/parameters/originalURLParam'
|
||||
- $ref: '#/components/parameters/forwardedMethodParam'
|
||||
- $ref: '#/components/parameters/authParam'
|
||||
responses:
|
||||
"200":
|
||||
description: Successful Operation
|
||||
|
@ -481,6 +471,34 @@ paths:
|
|||
security:
|
||||
- authelia_auth: []
|
||||
components:
|
||||
parameters:
|
||||
originalURLParam:
|
||||
name: X-Original-URL
|
||||
in: header
|
||||
description: Redirection URL
|
||||
required: true
|
||||
style: simple
|
||||
explode: true
|
||||
schema:
|
||||
type: string
|
||||
forwardedMethodParam:
|
||||
name: X-Forwarded-Method
|
||||
in: header
|
||||
description: Request Method
|
||||
required: false
|
||||
style: simple
|
||||
explode: true
|
||||
schema:
|
||||
type: string
|
||||
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"]
|
||||
authParam:
|
||||
name: auth
|
||||
in: query
|
||||
description: Switch authorization header and prompt for basic auth
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: ["basic"]
|
||||
schemas:
|
||||
handlers.configuration.ConfigurationBody:
|
||||
type: object
|
||||
|
@ -517,6 +535,9 @@ components:
|
|||
targetURL:
|
||||
type: string
|
||||
example: https://home.example.com
|
||||
requestMethod:
|
||||
type: string
|
||||
example: GET
|
||||
keepMeLoggedIn:
|
||||
type: boolean
|
||||
example: true
|
||||
|
|
|
@ -8,7 +8,7 @@ nav_order: 1
|
|||
# Access Control
|
||||
{: .no_toc }
|
||||
|
||||
## Access Control List
|
||||
## Policies
|
||||
|
||||
With **Authelia** you can define a list of rules that are going to be evaluated in
|
||||
sequential order when authorization is delegated to Authelia.
|
||||
|
@ -16,8 +16,46 @@ sequential order when authorization is delegated to Authelia.
|
|||
The first matching rule of the list defines the policy applied to the resource, if
|
||||
no rule matches the resource a customizable default policy is applied.
|
||||
|
||||
### deny
|
||||
|
||||
## Access Control Rule
|
||||
This is the policy applied by default, and is what we recommend as the default policy for all installs. Its effect
|
||||
is literally to deny the user access to the resource. Additionally you can use this policy to conditionally deny
|
||||
access in desired situations. Examples include denying access to an API that has no authentication mechanism built in.
|
||||
|
||||
### bypass
|
||||
|
||||
This policy skips all authentication and allows anyone to use the resource. This policy is not available with a rule
|
||||
that includes a [subject](#Subjects) restriction because the minimum authentication level required to obtain information
|
||||
about the subject is [one_factor](#one_factor).
|
||||
|
||||
### one_factor
|
||||
|
||||
This policy requires the user at minimum complete 1FA successfully (username and password). This means if they have
|
||||
performed 2FA then they will be allowed to access the resource.
|
||||
|
||||
### two_factor
|
||||
|
||||
This policy requires the user to complete 2FA successfully. This is currently the highest level of authentication
|
||||
policy available.
|
||||
|
||||
## Default Policy
|
||||
|
||||
The default policy is the policy applied when no other rule matches. It is recommended that this is configured to
|
||||
[deny](#deny) for security reasons. Sites which you do not wish to secure with Authelia should not be configured to
|
||||
perform authentication with Authelia at all.
|
||||
|
||||
See [Policies](#policies) for more information.
|
||||
|
||||
## Network Aliases
|
||||
|
||||
The main networks section defines a list of network aliases, where the name matches a list of networks. These names can
|
||||
be used in any [rule](#rules) instead of a literal network. This makes it easier to define a group of networks multiple
|
||||
times.
|
||||
|
||||
You can combine both literal networks and these aliases inside the [networks](#networks) section of a rule. See this
|
||||
section for more details.
|
||||
|
||||
## Rules
|
||||
|
||||
A rule defines two things:
|
||||
|
||||
|
@ -26,29 +64,24 @@ A rule defines two things:
|
|||
|
||||
The criteria are:
|
||||
|
||||
* domain: domain targeted by the request.
|
||||
* resources: list of patterns that the path should match (one is sufficient).
|
||||
* domain: domain or list of domains targeted by the request.
|
||||
* resources: pattern or list of patterns that the path should match.
|
||||
* subject: the user or group of users to define the policy for.
|
||||
* networks: the network addresses, ranges (CIDR notation) or groups from where the request originates.
|
||||
* methods: the http methods used in the request.
|
||||
|
||||
A rule is matched when all criteria of the rule match.
|
||||
A rule is matched when all criteria of the rule match. Rules are evaluated in sequential order, and this is
|
||||
particularly **important** for bypass rules. Bypass rules should generally appear near the top of the rules list.
|
||||
|
||||
|
||||
## Policies
|
||||
### Policy
|
||||
|
||||
A policy represents the level of authentication the user needs to pass before
|
||||
being authorized to request the resource.
|
||||
|
||||
There exist 4 policies:
|
||||
See [Policies](#policies) for more information.
|
||||
|
||||
* bypass: the resource is public as the user does not need any authentication to
|
||||
get access to it.
|
||||
* one_factor: the user needs to pass at least the first factor to get access to
|
||||
the resource.
|
||||
* two_factor: the user needs to pass two factors to get access to the resource.
|
||||
* deny: the user does not have access to the resource.
|
||||
|
||||
## Domains
|
||||
### Domains
|
||||
|
||||
The domains defined in rules must obviously be either a subdomain of the domain
|
||||
protected by Authelia or the protected domain itself. In order to match multiple
|
||||
|
@ -58,7 +91,12 @@ For instance, to define a rule for all subdomains of *example.com*, one would us
|
|||
These domains can be either listed in YAML-short form `["example1.com", "example2.com"]`
|
||||
or in YAML long-form as dashed list.
|
||||
|
||||
## Resources
|
||||
Domain prefixes can also be dynamically match users or groups. For example you can have a
|
||||
specific policy adjustment if the user or group matches the subdomain. For
|
||||
example `{user}.example.com` or `{group}.example.com` check the users name or
|
||||
groups against the subdomain.
|
||||
|
||||
### Resources
|
||||
|
||||
A rule can define multiple regular expressions for matching the path of the resource
|
||||
similar to the list of domains. If any one of them matches, the resource criteria of
|
||||
|
@ -72,7 +110,7 @@ when you are using regular expressions, you enclose them between quotes. It's op
|
|||
it will likely save you a lot of debugging time.
|
||||
|
||||
|
||||
## Subjects
|
||||
### Subjects
|
||||
|
||||
A subject is a representation of a user or a group of user for who the rule should apply.
|
||||
|
||||
|
@ -86,20 +124,52 @@ In summary, the first list level of subjects are evaluated using a logical `OR`,
|
|||
second level by a logical `AND`. The last example below reads as: the group is `dev` AND the
|
||||
username is `john` OR the group is `admins`.
|
||||
|
||||
## Networks
|
||||
#### Combining subjects and the bypass policy
|
||||
|
||||
A subject cannot be combined with the `bypass` policy since the minimum authentication level to identify a subject is
|
||||
`one_factor`. Combining the `one_factor` policy with a subject is effectively the same as setting the policy to `bypass`
|
||||
in the past. We have taken an opinionated stance on preventing this configuration as it could result in problematic
|
||||
security scenarios with badly thought out configurations and cannot see a likely configuration scenario that would
|
||||
require users to do this. If you have a scenario in mind please open an
|
||||
[issue](https://github.com/authelia/authelia/issues/new) on GitHub.
|
||||
|
||||
### Networks
|
||||
|
||||
A list of network addresses, ranges (CIDR notation) or groups can be specified in a rule in order to apply different
|
||||
policies when requests originate from different networks.
|
||||
policies when requests originate from different networks. This list can contain both literal definitions of networks
|
||||
and [network aliases](#network-aliases).
|
||||
|
||||
The main use case is when, lets say a resource should be exposed both on the Internet and from an
|
||||
Main use cases for this rule option is to adjust the security requirements of a resource based on the location of
|
||||
the user. For example lets say a resource should be exposed both on the Internet and from an
|
||||
authenticated VPN for instance. Passing a second factor a first time to get access to the VPN and
|
||||
a second time to get access to the application can sometimes be cumbersome if the endpoint is not
|
||||
considered overly sensitive.
|
||||
|
||||
An additional situation where this may be useful is if there is a specific network you wish to deny access
|
||||
or require a higher level of authentication for; like a public machine network vs a company device network, or a
|
||||
BYOD network.
|
||||
|
||||
Even if Authelia provides this flexibility, you might prefer a higher level of security and avoid
|
||||
this option entirely. You and only you can define your security policy and it's up to you to
|
||||
configure Authelia accordingly.
|
||||
|
||||
### Methods
|
||||
|
||||
A list of HTTP request methods to apply the rule to. Valid values are GET, HEAD, POST, PUT, DELETE,
|
||||
CONNECT, OPTIONS, and TRACE. Additional information about HTTP request methods can be found on the
|
||||
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods).
|
||||
|
||||
It's important to note this policy type is primarily intended for use when you wish to bypass authentication for
|
||||
a specific request method. This is because there are several key limitations in what is possible to accomplish
|
||||
without Authelia being a reverse proxy server. This rule type is discouraged unless you really know what you're
|
||||
doing or you wish to setup a rule to bypass CORS preflight requests by bypassing for the OPTIONS method.
|
||||
|
||||
For example, if you require authentication only for write events (POST, PATCH, DELETE, PUT), when a user who is not
|
||||
currently authenticated tries to do one of these actions, they will be redirected to Authelia. Authelia will decide
|
||||
what level is required for authentication, and then after the user authenticates it will redirect them to the original
|
||||
URL where Authelia decided they needed to authenticate. So if the endpoint they are redirected to originally had
|
||||
data sent as part of the request, this data is completely lost. Further if the endpoint expects the data or doesn't allow
|
||||
GET request types, the user may be presented with an error leading to a bad user experience.
|
||||
|
||||
## Complete example
|
||||
|
||||
|
@ -119,6 +189,11 @@ access_control:
|
|||
- domain: public.example.com
|
||||
policy: bypass
|
||||
|
||||
- domain: "*.example.com"
|
||||
policy: bypass
|
||||
methods:
|
||||
- OPTIONS
|
||||
|
||||
- domain: secure.example.com
|
||||
policy: one_factor
|
||||
networks:
|
||||
|
@ -158,4 +233,7 @@ access_control:
|
|||
- ["group:dev", "user:john"]
|
||||
- "group:admins"
|
||||
policy: two_factor
|
||||
|
||||
- domain: "{user}.example.com"
|
||||
policy: bypass
|
||||
```
|
||||
|
|
|
@ -102,8 +102,21 @@ frontend fe_http
|
|||
http-request set-var(req.scheme) str(http) if !{ ssl_fc }
|
||||
http-request set-var(req.questionmark) str(?) if { query -m found }
|
||||
|
||||
# These are optional if you wish to use the Methods rule in the access_control section.
|
||||
#http-request set-var(req.method) str(CONNECT) if { method CONNECT }
|
||||
#http-request set-var(req.method) str(GET) if { method GET }
|
||||
#http-request set-var(req.method) str(HEAD) if { method HEAD }
|
||||
#http-request set-var(req.method) str(OPTIONS) if { method OPTIONS }
|
||||
#http-request set-var(req.method) str(POST) if { method POST }
|
||||
#http-request set-var(req.method) str(TRACE) if { method TRACE }
|
||||
#http-request set-var(req.method) str(PUT) if { method PUT }
|
||||
#http-request set-var(req.method) str(PATCH) if { method PATCH }
|
||||
#http-request set-var(req.method) str(DELETE) if { method DELETE }
|
||||
#http-request set-header X-Forwarded-Method %[var(req.method)]
|
||||
|
||||
# Required headers
|
||||
http-request set-header X-Real-IP %[src]
|
||||
http-request set-header X-Forwarded-Method %[var(req.method)]
|
||||
http-request set-header X-Forwarded-Proto %[var(req.scheme)]
|
||||
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
|
||||
http-request set-header X-Forwarded-Uri %[path]%[var(req.questionmark)]%[query]
|
||||
|
@ -180,6 +193,18 @@ frontend fe_http
|
|||
http-request set-var(req.scheme) str(http) if !{ ssl_fc }
|
||||
http-request set-var(req.questionmark) str(?) if { query -m found }
|
||||
|
||||
# These are optional if you wish to use the Methods rule in the access_control section.
|
||||
#http-request set-var(req.method) str(CONNECT) if { method CONNECT }
|
||||
#http-request set-var(req.method) str(GET) if { method GET }
|
||||
#http-request set-var(req.method) str(HEAD) if { method HEAD }
|
||||
#http-request set-var(req.method) str(OPTIONS) if { method OPTIONS }
|
||||
#http-request set-var(req.method) str(POST) if { method POST }
|
||||
#http-request set-var(req.method) str(TRACE) if { method TRACE }
|
||||
#http-request set-var(req.method) str(PUT) if { method PUT }
|
||||
#http-request set-var(req.method) str(PATCH) if { method PATCH }
|
||||
#http-request set-var(req.method) str(DELETE) if { method DELETE }
|
||||
#http-request set-header X-Forwarded-Method %[var(req.method)]
|
||||
|
||||
# Required headers
|
||||
http-request set-header X-Real-IP %[src]
|
||||
http-request set-header X-Forwarded-Proto %[var(req.scheme)]
|
||||
|
|
|
@ -48,10 +48,11 @@ location /authelia {
|
|||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Method $request_method;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
|
@ -208,10 +209,11 @@ location /authelia {
|
|||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Method $request_method;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Ssl on;
|
||||
proxy_redirect http:// $scheme://;
|
||||
proxy_http_version 1.1;
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// AccessControlDomain represents an ACL domain.
|
||||
type AccessControlDomain struct {
|
||||
Name string
|
||||
Wildcard bool
|
||||
UserWildcard bool
|
||||
GroupWildcard bool
|
||||
}
|
||||
|
||||
// IsMatch returns true if the ACL domain matches the object domain.
|
||||
func (acd AccessControlDomain) IsMatch(subject Subject, object Object) (match bool) {
|
||||
switch {
|
||||
case acd.Wildcard:
|
||||
return strings.HasSuffix(object.Domain, acd.Name)
|
||||
case acd.UserWildcard:
|
||||
return object.Domain == fmt.Sprintf("%s.%s", subject.Username, acd.Name)
|
||||
case acd.GroupWildcard:
|
||||
prefix, suffix := domainToPrefixSuffix(object.Domain)
|
||||
|
||||
return suffix == acd.Name && utils.IsStringInSliceFold(prefix, subject.Groups)
|
||||
default:
|
||||
return object.Domain == acd.Name
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// AccessControlResource represents an ACL resource.
|
||||
type AccessControlResource struct {
|
||||
Pattern *regexp.Regexp
|
||||
}
|
||||
|
||||
// IsMatch returns true if the ACL resource match the object path.
|
||||
func (acr AccessControlResource) IsMatch(object Object) (match bool) {
|
||||
return acr.Pattern.MatchString(object.Path)
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// NewAccessControlRules converts a schema.AccessControlConfiguration into an AccessControlRule slice.
|
||||
func NewAccessControlRules(config schema.AccessControlConfiguration) (rules []*AccessControlRule) {
|
||||
networksMap, networksCacheMap := parseSchemaNetworks(config.Networks)
|
||||
|
||||
for _, schemaRule := range config.Rules {
|
||||
rules = append(rules, NewAccessControlRule(schemaRule, networksMap, networksCacheMap))
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// NewAccessControlRule parses a schema ACL and generates an internal ACL.
|
||||
func NewAccessControlRule(rule schema.ACLRule, networksMap map[string][]*net.IPNet, networksCacheMap map[string]*net.IPNet) *AccessControlRule {
|
||||
return &AccessControlRule{
|
||||
Domains: schemaDomainsToACL(rule.Domains),
|
||||
Resources: schemaResourcesToACL(rule.Resources),
|
||||
Methods: schemaMethodsToACL(rule.Methods),
|
||||
Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap),
|
||||
Subjects: schemaSubjectsToACL(rule.Subjects),
|
||||
Policy: PolicyToLevel(rule.Policy),
|
||||
}
|
||||
}
|
||||
|
||||
// AccessControlRule controls and represents an ACL internally.
|
||||
type AccessControlRule struct {
|
||||
Domains []AccessControlDomain
|
||||
Resources []AccessControlResource
|
||||
Methods []string
|
||||
Networks []*net.IPNet
|
||||
Subjects []AccessControlSubjects
|
||||
Policy Level
|
||||
}
|
||||
|
||||
// IsMatch returns true if all elements of an AccessControlRule match the object and subject.
|
||||
func (acr *AccessControlRule) IsMatch(subject Subject, object Object) (match bool) {
|
||||
if !isMatchForDomains(subject, object, acr) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isMatchForResources(object, acr) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isMatchForMethods(object, acr) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isMatchForNetworks(subject, acr) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isMatchForSubjects(subject, acr) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isMatchForDomains(subject Subject, object Object, acl *AccessControlRule) (match bool) {
|
||||
// If there are no domains in this rule then the domain condition is a match.
|
||||
if len(acl.Domains) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Iterate over the domains until we find a match (return true) or until we exit the loop (return false).
|
||||
for _, domain := range acl.Domains {
|
||||
if domain.IsMatch(subject, object) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isMatchForResources(object Object, acl *AccessControlRule) (match bool) {
|
||||
// If there are no resources in this rule then the resource condition is a match.
|
||||
if len(acl.Resources) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Iterate over the resources until we find a match (return true) or until we exit the loop (return false).
|
||||
for _, resource := range acl.Resources {
|
||||
if resource.IsMatch(object) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isMatchForMethods(object Object, acl *AccessControlRule) (match bool) {
|
||||
// If there are no methods in this rule then the method condition is a match.
|
||||
if len(acl.Methods) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return utils.IsStringInSlice(object.Method, acl.Methods)
|
||||
}
|
||||
|
||||
func isMatchForNetworks(subject Subject, acl *AccessControlRule) (match bool) {
|
||||
// If there are no networks in this rule then the network condition is a match.
|
||||
if len(acl.Networks) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Iterate over the networks until we find a match (return true) or until we exit the loop (return false).
|
||||
for _, network := range acl.Networks {
|
||||
if network.Contains(subject.IP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isMatchForSubjects(subject Subject, acl *AccessControlRule) (match bool) {
|
||||
// If there are no subjects in this rule then the subject condition is a match.
|
||||
if len(acl.Subjects) == 0 || subject.IsAnonymous() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Iterate over the subjects until we find a match (return true) or until we exit the loop (return false).
|
||||
for _, subjectRule := range acl.Subjects {
|
||||
if subjectRule.IsMatch(subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// AccessControlSubject abstracts an ACL subject of type `group:` or `user:`.
|
||||
type AccessControlSubject interface {
|
||||
IsMatch(subject Subject) (match bool)
|
||||
}
|
||||
|
||||
// AccessControlSubjects represents an ACL subject.
|
||||
type AccessControlSubjects struct {
|
||||
Subjects []AccessControlSubject
|
||||
}
|
||||
|
||||
// AddSubject appends to the AccessControlSubjects based on a subject rule string.
|
||||
func (acs *AccessControlSubjects) AddSubject(subjectRule string) {
|
||||
subject := schemaSubjectToACLSubject(subjectRule)
|
||||
|
||||
if subject != nil {
|
||||
acs.Subjects = append(acs.Subjects, subject)
|
||||
}
|
||||
}
|
||||
|
||||
// IsMatch returns true if the ACL subjects match the subject properties.
|
||||
func (acs AccessControlSubjects) IsMatch(subject Subject) (match bool) {
|
||||
for _, rule := range acs.Subjects {
|
||||
if !rule.IsMatch(subject) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// AccessControlUser represents an ACL subject of type `user:`.
|
||||
type AccessControlUser struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsMatch returns true if the AccessControlUser name matches the Subject username.
|
||||
func (acu AccessControlUser) IsMatch(subject Subject) (match bool) {
|
||||
return subject.Username == acu.Name
|
||||
}
|
||||
|
||||
// AccessControlGroup represents an ACL subject of type `group:`.
|
||||
type AccessControlGroup struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// IsMatch returns true if the AccessControlGroup name matches one of the groups of the Subject.
|
||||
func (acg AccessControlGroup) IsMatch(subject Subject) (match bool) {
|
||||
return utils.IsStringInSlice(acg.Name, subject.Groups)
|
||||
}
|
|
@ -1,110 +1,32 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
"github.com/authelia/authelia/internal/logging"
|
||||
)
|
||||
|
||||
const userPrefix = "user:"
|
||||
const groupPrefix = "group:"
|
||||
|
||||
// Authorizer the component in charge of checking whether a user can access a given resource.
|
||||
type Authorizer struct {
|
||||
configuration schema.AccessControlConfiguration
|
||||
defaultPolicy Level
|
||||
rules []*AccessControlRule
|
||||
}
|
||||
|
||||
// NewAuthorizer create an instance of authorizer with a given access control configuration.
|
||||
func NewAuthorizer(configuration schema.AccessControlConfiguration) *Authorizer {
|
||||
return &Authorizer{
|
||||
configuration: configuration,
|
||||
defaultPolicy: PolicyToLevel(configuration.DefaultPolicy),
|
||||
rules: NewAccessControlRules(configuration),
|
||||
}
|
||||
}
|
||||
|
||||
// Subject subject who to check access control for.
|
||||
type Subject struct {
|
||||
Username string
|
||||
Groups []string
|
||||
IP net.IP
|
||||
}
|
||||
|
||||
func (s Subject) String() string {
|
||||
return fmt.Sprintf("username=%s groups=%s ip=%s", s.Username, strings.Join(s.Groups, ","), s.IP.String())
|
||||
}
|
||||
|
||||
// Object object to check access control for.
|
||||
type Object struct {
|
||||
Domain string
|
||||
Path string
|
||||
}
|
||||
|
||||
// selectMatchingSubjectRules take a set of rules and select only the rules matching the subject constraints.
|
||||
func selectMatchingSubjectRules(rules []schema.ACLRule, networks []schema.ACLNetwork, subject Subject) []schema.ACLRule {
|
||||
selectedRules := []schema.ACLRule{}
|
||||
|
||||
for _, rule := range rules {
|
||||
switch {
|
||||
case len(rule.Subjects) > 0:
|
||||
for _, subjectRule := range rule.Subjects {
|
||||
if isSubjectMatching(subject, subjectRule) && isIPMatching(subject.IP, rule.Networks, networks) {
|
||||
selectedRules = append(selectedRules, rule)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if isIPMatching(subject.IP, rule.Networks, networks) {
|
||||
selectedRules = append(selectedRules, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedRules
|
||||
}
|
||||
|
||||
func selectMatchingObjectRules(rules []schema.ACLRule, object Object) []schema.ACLRule {
|
||||
selectedRules := []schema.ACLRule{}
|
||||
|
||||
for _, rule := range rules {
|
||||
if isDomainMatching(object.Domain, rule.Domains) && isPathMatching(object.Path, rule.Resources) {
|
||||
selectedRules = append(selectedRules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
return selectedRules
|
||||
}
|
||||
|
||||
func selectMatchingRules(rules []schema.ACLRule, networks []schema.ACLNetwork, subject Subject, object Object) []schema.ACLRule {
|
||||
matchingRules := selectMatchingSubjectRules(rules, networks, subject)
|
||||
return selectMatchingObjectRules(matchingRules, object)
|
||||
}
|
||||
|
||||
// PolicyToLevel converts a string policy to int authorization level.
|
||||
func PolicyToLevel(policy string) Level {
|
||||
switch policy {
|
||||
case "bypass":
|
||||
return Bypass
|
||||
case "one_factor":
|
||||
return OneFactor
|
||||
case "two_factor":
|
||||
return TwoFactor
|
||||
case "deny":
|
||||
return Denied
|
||||
}
|
||||
// By default the deny policy applies.
|
||||
return Denied
|
||||
}
|
||||
|
||||
// IsSecondFactorEnabled return true if at least one policy is set to second factor.
|
||||
func (p *Authorizer) IsSecondFactorEnabled() bool {
|
||||
if PolicyToLevel(p.configuration.DefaultPolicy) == TwoFactor {
|
||||
if p.defaultPolicy == TwoFactor {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, r := range p.configuration.Rules {
|
||||
if PolicyToLevel(r.Policy) == TwoFactor {
|
||||
for _, rule := range p.rules {
|
||||
if rule.Policy == TwoFactor {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -113,20 +35,17 @@ func (p *Authorizer) IsSecondFactorEnabled() bool {
|
|||
}
|
||||
|
||||
// GetRequiredLevel retrieve the required level of authorization to access the object.
|
||||
func (p *Authorizer) GetRequiredLevel(subject Subject, requestURL url.URL) Level {
|
||||
func (p *Authorizer) GetRequiredLevel(subject Subject, object Object) Level {
|
||||
logger := logging.Logger()
|
||||
logger.Tracef("Check authorization of subject %s and url %s.", subject.String(), requestURL.String())
|
||||
logger.Tracef("Check authorization of subject %s and url %s.", subject.String(), object.String())
|
||||
|
||||
matchingRules := selectMatchingRules(p.configuration.Rules, p.configuration.Networks, subject, Object{
|
||||
Domain: requestURL.Hostname(),
|
||||
Path: requestURL.Path,
|
||||
})
|
||||
|
||||
if len(matchingRules) > 0 {
|
||||
return PolicyToLevel(matchingRules[0].Policy)
|
||||
for _, rule := range p.rules {
|
||||
if rule.IsMatch(subject, object) {
|
||||
return rule.Policy
|
||||
}
|
||||
}
|
||||
|
||||
logger.Tracef("No matching rule for subject %s and url %s... Applying default policy.", subject.String(), requestURL.String())
|
||||
logger.Tracef("No matching rule for subject %s and url %s... Applying default policy.", subject.String(), object.String())
|
||||
|
||||
return PolicyToLevel(p.configuration.DefaultPolicy)
|
||||
return p.defaultPolicy
|
||||
}
|
||||
|
|
|
@ -25,13 +25,17 @@ func NewAuthorizerTester(config schema.AccessControlConfiguration) *AuthorizerTe
|
|||
}
|
||||
}
|
||||
|
||||
func (s *AuthorizerTester) CheckAuthorizations(t *testing.T, subject Subject, requestURI string, expectedLevel Level) {
|
||||
func (s *AuthorizerTester) CheckAuthorizations(t *testing.T, subject Subject, requestURI, method string, expectedLevel Level) {
|
||||
url, _ := url.ParseRequestURI(requestURI)
|
||||
level := s.GetRequiredLevel(Subject{
|
||||
Groups: subject.Groups,
|
||||
Username: subject.Username,
|
||||
IP: subject.IP,
|
||||
}, *url)
|
||||
|
||||
object := Object{
|
||||
Scheme: url.Scheme,
|
||||
Domain: url.Hostname(),
|
||||
Path: url.Path,
|
||||
Method: method,
|
||||
}
|
||||
|
||||
level := s.GetRequiredLevel(subject, object)
|
||||
|
||||
assert.Equal(t, expectedLevel, level)
|
||||
}
|
||||
|
@ -80,24 +84,40 @@ var UserWithoutGroups = Subject{
|
|||
|
||||
var Bob = UserWithoutGroups
|
||||
|
||||
var UserWithIPv6Address = Subject{
|
||||
Username: "sam",
|
||||
Groups: []string{},
|
||||
IP: net.ParseIP("fec0::1"),
|
||||
}
|
||||
|
||||
var Sam = UserWithIPv6Address
|
||||
|
||||
var UserWithIPv6AddressAndGroups = Subject{
|
||||
Username: "sam",
|
||||
Groups: []string{"dev", "admins"},
|
||||
IP: net.ParseIP("fec0::2"),
|
||||
}
|
||||
|
||||
var Sally = UserWithIPv6AddressAndGroups
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckDefaultBypassConfig() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithDefaultPolicy("bypass").Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/elsewhere", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/elsewhere", "GET", Bypass)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckDefaultDeniedConfig() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithDefaultPolicy("deny").Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/elsewhere", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithoutGroups, "https://public.example.com/elsewhere", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckMultiDomainRule() {
|
||||
|
@ -109,12 +129,31 @@ func (s *AuthorizerSuite) TestShouldCheckMultiDomainRule() {
|
|||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/elsewhere", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com.c/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.co/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/elsewhere", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com.c/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.co/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckDynamicDomainRules() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"{user}.example.com"},
|
||||
Policy: "bypass",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"{group}.example.com"},
|
||||
Policy: "bypass",
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://john.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://dev.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://admins.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://othergroup.example.com/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckMultipleDomainRule() {
|
||||
|
@ -126,15 +165,15 @@ func (s *AuthorizerSuite) TestShouldCheckMultipleDomainRule() {
|
|||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/elsewhere", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com.c/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.co/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://other.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://other.com/elsewhere", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.other.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/elsewhere", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com.c/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.co/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://other.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://other.com/elsewhere", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://private.other.com/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckFactorsPolicy() {
|
||||
|
@ -154,10 +193,10 @@ func (s *AuthorizerSuite) TestShouldCheckFactorsPolicy() {
|
|||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://protected.example.com/", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://single.example.com/", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://public.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://protected.example.com/", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://single.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() {
|
||||
|
@ -178,9 +217,9 @@ func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() {
|
|||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", "GET", TwoFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckUserMatching() {
|
||||
|
@ -188,13 +227,13 @@ func (s *AuthorizerSuite) TestShouldCheckUserMatching() {
|
|||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "bypass",
|
||||
Policy: "one_factor",
|
||||
Subjects: [][]string{{"user:john"}},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckGroupMatching() {
|
||||
|
@ -202,13 +241,13 @@ func (s *AuthorizerSuite) TestShouldCheckGroupMatching() {
|
|||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "bypass",
|
||||
Policy: "one_factor",
|
||||
Subjects: [][]string{{"group:admins"}},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckSubjectsMatching() {
|
||||
|
@ -216,14 +255,15 @@ func (s *AuthorizerSuite) TestShouldCheckSubjectsMatching() {
|
|||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "bypass",
|
||||
Policy: "one_factor",
|
||||
Subjects: [][]string{{"group:admins"}, {"user:bob"}},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Sam, "https://protected.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "GET", OneFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckMultipleSubjectsMatching() {
|
||||
|
@ -231,14 +271,14 @@ func (s *AuthorizerSuite) TestShouldCheckMultipleSubjectsMatching() {
|
|||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "bypass",
|
||||
Policy: "one_factor",
|
||||
Subjects: [][]string{{"group:admins", "user:bob"}, {"group:admins", "group:dev"}},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "GET", OneFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckIPMatching() {
|
||||
|
@ -259,15 +299,63 @@ func (s *AuthorizerSuite) TestShouldCheckIPMatching() {
|
|||
Policy: "two_factor",
|
||||
Networks: []string{"10.0.0.0/8"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"ipv6.example.com"},
|
||||
Policy: "two_factor",
|
||||
Networks: []string{"fec0::1/64"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"ipv6-alt.example.com"},
|
||||
Policy: "two_factor",
|
||||
Networks: []string{"fec0::1"},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "GET", Denied)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://net.example.com/", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://net.example.com/", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://net.example.com/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://net.example.com/", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://net.example.com/", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://net.example.com/", "GET", Denied)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), Sally, "https://ipv6-alt.example.com/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), Sam, "https://ipv6-alt.example.com/", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), Sally, "https://ipv6.example.com/", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), Sam, "https://ipv6.example.com/", "GET", TwoFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckMethodMatching() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "bypass",
|
||||
Methods: []string{"OPTIONS", "HEAD", "GET", "CONNECT", "TRACE"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "one_factor",
|
||||
Methods: []string{"PUT", "PATCH", "POST"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"protected.example.com"},
|
||||
Policy: "two_factor",
|
||||
Methods: []string{"DELETE"},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "OPTIONS", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "HEAD", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "CONNECT", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "TRACE", Bypass)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://protected.example.com/", "PUT", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://protected.example.com/", "PATCH", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "POST", OneFactor)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://protected.example.com/", "DELETE", TwoFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldCheckResourceMatching() {
|
||||
|
@ -285,12 +373,109 @@ func (s *AuthorizerSuite) TestShouldCheckResourceMatching() {
|
|||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/abc", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/ABC", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/one_factor/abc", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/xyz/embedded/abc", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/abc", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/bypass/ABC", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/one_factor/abc", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/xyz/embedded/abc", "GET", Bypass)
|
||||
}
|
||||
|
||||
// This test assures that rules without domains (not allowed by schema validator at this time) will pass validation correctly.
|
||||
func (s *AuthorizerSuite) TestShouldMatchAnyDomainIfBlank() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithRule(schema.ACLRule{
|
||||
Policy: "bypass",
|
||||
Methods: []string{"OPTIONS", "HEAD", "GET", "CONNECT", "TRACE"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Policy: "one_factor",
|
||||
Methods: []string{"PUT", "PATCH"},
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Policy: "two_factor",
|
||||
Methods: []string{"DELETE"},
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://one.domain-four.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-three.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-two.com", "OPTIONS", Bypass)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://one.domain-four.com", "PUT", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-three.com", "PATCH", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-two.com", "PUT", OneFactor)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://one.domain-four.com", "DELETE", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-three.com", "DELETE", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-two.com", "DELETE", TwoFactor)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://one.domain-four.com", "POST", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-three.com", "POST", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one.domain-two.com", "POST", Denied)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() {
|
||||
tester := NewAuthorizerBuilder().
|
||||
WithDefaultPolicy("deny").
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public.example.com"},
|
||||
Resources: []string{"^/admin/.*$"},
|
||||
Subjects: [][]string{{"group:admins"}},
|
||||
Policy: "one_factor",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public.example.com"},
|
||||
Resources: []string{"^/admin/.*$"},
|
||||
Policy: "deny",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public.example.com"},
|
||||
Policy: "bypass",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public2.example.com"},
|
||||
Resources: []string{"^/admin/.*$"},
|
||||
Subjects: [][]string{{"group:admins"}},
|
||||
Policy: "bypass",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public2.example.com"},
|
||||
Resources: []string{"^/admin/.*$"},
|
||||
Policy: "deny",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"public2.example.com"},
|
||||
Policy: "bypass",
|
||||
}).
|
||||
WithRule(schema.ACLRule{
|
||||
Domains: []string{"private.example.com"},
|
||||
Subjects: [][]string{{"group:admins"}},
|
||||
Policy: "two_factor",
|
||||
}).
|
||||
Build()
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public.example.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://public.example.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com", "GET", Bypass)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public.example.com/admin/index.html", "GET", OneFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://public.example.com/admin/index.html", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com/admin/index.html", "GET", OneFactor)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public2.example.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://public2.example.com", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public2.example.com", "GET", Bypass)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://public2.example.com/admin/index.html", "GET", Bypass)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://public2.example.com/admin/index.html", "GET", Denied)
|
||||
|
||||
// This test returns this result since we validate the schema instead of validating it in code.
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public2.example.com/admin/index.html", "GET", Bypass)
|
||||
|
||||
tester.CheckAuthorizations(s.T(), John, "https://private.example.com", "GET", TwoFactor)
|
||||
tester.CheckAuthorizations(s.T(), Bob, "https://private.example.com", "GET", Denied)
|
||||
tester.CheckAuthorizations(s.T(), AnonymousUser, "https://private.example.com", "GET", TwoFactor)
|
||||
}
|
||||
|
||||
func (s *AuthorizerSuite) TestPolicyToLevel() {
|
||||
|
|
|
@ -13,3 +13,6 @@ const (
|
|||
// Denied denied level.
|
||||
Denied Level = iota
|
||||
)
|
||||
|
||||
const userPrefix = "user:"
|
||||
const groupPrefix = "group:"
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import "strings"
|
||||
|
||||
func isDomainMatching(domain string, domainRules []string) bool {
|
||||
for _, domainRule := range domainRules {
|
||||
if domain == domainRule {
|
||||
return true
|
||||
} else if strings.HasPrefix(domainRule, "*.") && strings.HasSuffix(domain, domainRule[1:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShouldMatchACLWithSingleDomain(t *testing.T) {
|
||||
assert.True(t, isDomainMatching("example.com", []string{"example.com"}))
|
||||
|
||||
assert.True(t, isDomainMatching("abc.example.com", []string{"*.example.com"}))
|
||||
assert.True(t, isDomainMatching("abc.def.example.com", []string{"*.example.com"}))
|
||||
}
|
||||
|
||||
func TestShouldNotMatchACLWithSingleDomain(t *testing.T) {
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*.example.com"}))
|
||||
// Character * must be followed by . to be valid.
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*example.com"}))
|
||||
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*.exampl.com"}))
|
||||
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*.other.net"}))
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*other.net"}))
|
||||
assert.False(t, isDomainMatching("example.com", []string{"other.net"}))
|
||||
}
|
||||
|
||||
func TestShouldMatchACLWithMultipleDomains(t *testing.T) {
|
||||
assert.True(t, isDomainMatching("example.com", []string{"*.example.com", "example.com"}))
|
||||
assert.True(t, isDomainMatching("apple.example.com", []string{"*.example.com", "example.com"}))
|
||||
}
|
||||
|
||||
func TestShouldNotMatchACLWithMultipleDomains(t *testing.T) {
|
||||
assert.False(t, isDomainMatching("example.com", []string{"*.example.com", "*example.com"}))
|
||||
assert.False(t, isDomainMatching("apple.example.com", []string{"*example.com", "example.com"}))
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
func selectMatchingNetworkGroups(networks []string, aclNetworks []schema.ACLNetwork) []schema.ACLNetwork {
|
||||
selectedNetworkGroups := []schema.ACLNetwork{}
|
||||
|
||||
for _, network := range networks {
|
||||
for _, n := range aclNetworks {
|
||||
for _, ng := range n.Name {
|
||||
if network == ng {
|
||||
selectedNetworkGroups = append(selectedNetworkGroups, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedNetworkGroups
|
||||
}
|
||||
|
||||
func isIPAddressOrCIDR(ip net.IP, network string) bool {
|
||||
switch {
|
||||
case ip.String() == network:
|
||||
return true
|
||||
case strings.Contains(network, "/"):
|
||||
return parseCIDR(ip, network)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCIDR(ip net.IP, network string) bool {
|
||||
_, ipNet, _ := net.ParseCIDR(network)
|
||||
return ipNet.Contains(ip)
|
||||
}
|
||||
|
||||
// isIPMatching check whether user's IP is in one of the network ranges.
|
||||
func isIPMatching(ip net.IP, networks []string, aclNetworks []schema.ACLNetwork) bool {
|
||||
// If no network is provided in the rule, we match any network
|
||||
if len(networks) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
matchingNetworkGroups := selectMatchingNetworkGroups(networks, aclNetworks)
|
||||
|
||||
for _, network := range networks {
|
||||
if net.ParseIP(network) == nil && !strings.Contains(network, "/") {
|
||||
for _, n := range matchingNetworkGroups {
|
||||
for _, network := range n.Networks {
|
||||
if isIPAddressOrCIDR(ip, network) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isIPAddressOrCIDR(ip, network) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
func TestIPMatcher(t *testing.T) {
|
||||
// Default policy is 'allow all ips' if no IP is defined
|
||||
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"127.0.0.1"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.1"), []string{"127.0.0.1"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("not-an-ip"), []string{"127.0.0.1"}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.1"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"10.0.0.0/8"}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"10.0.0.0/8"}, schema.DefaultACLNetwork))
|
||||
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"192.168.0.0/24", "10.0.0.0/8"}, schema.DefaultACLNetwork))
|
||||
|
||||
// Test network groups
|
||||
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.True(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"localhost"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.1"), []string{"localhost"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("not-an-ip"), []string{"localhost"}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"internal"}, schema.DefaultACLNetwork))
|
||||
assert.False(t, isIPMatching(net.ParseIP("127.0.0.1"), []string{"internal"}, schema.DefaultACLNetwork))
|
||||
|
||||
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"internal"}, schema.DefaultACLNetwork))
|
||||
assert.True(t, isIPMatching(net.ParseIP("10.230.5.1"), []string{"192.168.0.0/24", "internal"}, schema.DefaultACLNetwork))
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import "regexp"
|
||||
|
||||
func isPathMatching(path string, pathRegexps []string) bool {
|
||||
// If there is no regexp patterns, it means that we match any path.
|
||||
if len(pathRegexps) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, pathRegexp := range pathRegexps {
|
||||
match, _ := regexp.MatchString(pathRegexp, path)
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPathMatcher(t *testing.T) {
|
||||
// Matching any path if no regexp is provided
|
||||
assert.True(t, isPathMatching("/", []string{}))
|
||||
|
||||
assert.False(t, isPathMatching("/", []string{"^/api"}))
|
||||
assert.True(t, isPathMatching("/api/test", []string{"^/api"}))
|
||||
assert.False(t, isPathMatching("/api/test", []string{"^/api$"}))
|
||||
assert.True(t, isPathMatching("/api", []string{"^/api$"}))
|
||||
assert.True(t, isPathMatching("/api/test", []string{"^/api/?.*"}))
|
||||
assert.True(t, isPathMatching("/apitest", []string{"^/api/?.*"}))
|
||||
assert.True(t, isPathMatching("/api/test", []string{"^/api/.*"}))
|
||||
assert.True(t, isPathMatching("/api/", []string{"^/api/.*"}))
|
||||
assert.False(t, isPathMatching("/api", []string{"^/api/.*"}))
|
||||
|
||||
assert.False(t, isPathMatching("/api", []string{"xyz", "^/api/.*"}))
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
func isSubjectMatching(subject Subject, subjectRule []string) bool {
|
||||
for _, ruleSubject := range subjectRule {
|
||||
if strings.HasPrefix(ruleSubject, userPrefix) {
|
||||
user := strings.Trim(ruleSubject[len(userPrefix):], " ")
|
||||
if user == subject.Username {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ruleSubject, groupPrefix) {
|
||||
group := strings.Trim(ruleSubject[len(groupPrefix):], " ")
|
||||
if utils.IsStringInSlice(group, subject.Groups) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Subject represents the identity of a user for the purposes of ACL matching.
|
||||
type Subject struct {
|
||||
Username string
|
||||
Groups []string
|
||||
IP net.IP
|
||||
}
|
||||
|
||||
// String returns a string representation of the Subject.
|
||||
func (s Subject) String() string {
|
||||
return fmt.Sprintf("username=%s groups=%s ip=%s", s.Username, strings.Join(s.Groups, ","), s.IP.String())
|
||||
}
|
||||
|
||||
// IsAnonymous returns true if the Subject username and groups are empty.
|
||||
func (s Subject) IsAnonymous() bool {
|
||||
return s.Username == "" && len(s.Groups) == 0
|
||||
}
|
||||
|
||||
// Object represents a protected object for the purposes of ACL matching.
|
||||
type Object struct {
|
||||
Scheme string
|
||||
Domain string
|
||||
Path string
|
||||
Method string
|
||||
}
|
||||
|
||||
// String is a string representation of the Object.
|
||||
func (o Object) String() string {
|
||||
return fmt.Sprintf("%s://%s%s", o.Scheme, o.Domain, o.Path)
|
||||
}
|
||||
|
||||
// NewObjectRaw creates a new Object type from a URL and a method header.
|
||||
func NewObjectRaw(targetURL *url.URL, method []byte) (object Object) {
|
||||
return NewObject(targetURL, string(method))
|
||||
}
|
||||
|
||||
// NewObject creates a new Object type from a URL and a method header.
|
||||
func NewObject(targetURL *url.URL, method string) (object Object) {
|
||||
object = Object{
|
||||
Scheme: targetURL.Scheme,
|
||||
Domain: targetURL.Hostname(),
|
||||
Method: method,
|
||||
}
|
||||
|
||||
if targetURL.RawQuery == "" {
|
||||
object.Path = targetURL.Path
|
||||
} else {
|
||||
object.Path = targetURL.Path + "?" + targetURL.RawQuery
|
||||
}
|
||||
|
||||
return object
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldAppendQueryParamToURL(t *testing.T) {
|
||||
targetURL, err := url.Parse("https://domain.example.com/api?type=none")
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
object := NewObject(targetURL, "GET")
|
||||
|
||||
assert.Equal(t, "domain.example.com", object.Domain)
|
||||
assert.Equal(t, "GET", object.Method)
|
||||
assert.Equal(t, "/api?type=none", object.Path)
|
||||
assert.Equal(t, "https", object.Scheme)
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
// PolicyToLevel converts a string policy to int authorization level.
|
||||
func PolicyToLevel(policy string) Level {
|
||||
switch policy {
|
||||
case "bypass":
|
||||
return Bypass
|
||||
case "one_factor":
|
||||
return OneFactor
|
||||
case "two_factor":
|
||||
return TwoFactor
|
||||
case "deny":
|
||||
return Denied
|
||||
}
|
||||
// By default the deny policy applies.
|
||||
return Denied
|
||||
}
|
||||
|
||||
func schemaSubjectToACLSubject(subjectRule string) (subject AccessControlSubject) {
|
||||
if strings.HasPrefix(subjectRule, userPrefix) {
|
||||
user := strings.Trim(subjectRule[len(userPrefix):], " ")
|
||||
|
||||
return AccessControlUser{Name: user}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(subjectRule, groupPrefix) {
|
||||
group := strings.Trim(subjectRule[len(groupPrefix):], " ")
|
||||
|
||||
return AccessControlGroup{Name: group}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaDomainsToACL(domainRules []string) (domains []AccessControlDomain) {
|
||||
for _, domainRule := range domainRules {
|
||||
domain := AccessControlDomain{}
|
||||
|
||||
domainRule = strings.ToLower(domainRule)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(domainRule, "*."):
|
||||
domain.Wildcard = true
|
||||
domain.Name = domainRule[1:]
|
||||
case strings.HasPrefix(domainRule, "{user}"):
|
||||
domain.UserWildcard = true
|
||||
domain.Name = domainRule[7:]
|
||||
case strings.HasPrefix(domainRule, "{group}"):
|
||||
domain.GroupWildcard = true
|
||||
domain.Name = domainRule[8:]
|
||||
default:
|
||||
domain.Name = domainRule
|
||||
}
|
||||
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
|
||||
return domains
|
||||
}
|
||||
|
||||
func schemaResourcesToACL(resourceRules []string) (resources []AccessControlResource) {
|
||||
for _, resourceRule := range resourceRules {
|
||||
resources = append(resources, AccessControlResource{regexp.MustCompile(resourceRule)})
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
func schemaMethodsToACL(methodRules []string) (methods []string) {
|
||||
for _, method := range methodRules {
|
||||
methods = append(methods, strings.ToUpper(method))
|
||||
}
|
||||
|
||||
return methods
|
||||
}
|
||||
|
||||
func schemaNetworksToACL(networkRules []string, networksMap map[string][]*net.IPNet, networksCacheMap map[string]*net.IPNet) (networks []*net.IPNet) {
|
||||
for _, network := range networkRules {
|
||||
if _, ok := networksMap[network]; !ok {
|
||||
if _, ok := networksCacheMap[network]; ok {
|
||||
networks = append(networks, networksCacheMap[network])
|
||||
} else {
|
||||
cidr, err := parseNetwork(network)
|
||||
if err == nil {
|
||||
networks = append(networks, cidr)
|
||||
networksCacheMap[cidr.String()] = cidr
|
||||
|
||||
if cidr.String() != network {
|
||||
networksCacheMap[network] = cidr
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
networks = append(networks, networksMap[network]...)
|
||||
}
|
||||
}
|
||||
|
||||
return networks
|
||||
}
|
||||
|
||||
func parseSchemaNetworks(schemaNetworks []schema.ACLNetwork) (networksMap map[string][]*net.IPNet, networksCacheMap map[string]*net.IPNet) {
|
||||
// These maps store pointers to the net.IPNet values so we can reuse them efficiently.
|
||||
// The networksMap contains the named networks as keys, the networksCacheMap contains the CIDR notations as keys.
|
||||
networksMap = map[string][]*net.IPNet{}
|
||||
networksCacheMap = map[string]*net.IPNet{}
|
||||
|
||||
for _, aclNetwork := range schemaNetworks {
|
||||
var networks []*net.IPNet
|
||||
|
||||
for _, networkRule := range aclNetwork.Networks {
|
||||
cidr, err := parseNetwork(networkRule)
|
||||
if err == nil {
|
||||
networks = append(networks, cidr)
|
||||
networksCacheMap[cidr.String()] = cidr
|
||||
|
||||
if cidr.String() != networkRule {
|
||||
networksCacheMap[networkRule] = cidr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := networksMap[aclNetwork.Name]; len(networks) != 0 && !ok {
|
||||
networksMap[aclNetwork.Name] = networks
|
||||
}
|
||||
}
|
||||
|
||||
return networksMap, networksCacheMap
|
||||
}
|
||||
|
||||
func parseNetwork(networkRule string) (cidr *net.IPNet, err error) {
|
||||
if !strings.Contains(networkRule, "/") {
|
||||
ip := net.ParseIP(networkRule)
|
||||
if ip.To4() != nil {
|
||||
_, cidr, err = net.ParseCIDR(networkRule + "/32")
|
||||
} else {
|
||||
_, cidr, err = net.ParseCIDR(networkRule + "/128")
|
||||
}
|
||||
} else {
|
||||
_, cidr, err = net.ParseCIDR(networkRule)
|
||||
}
|
||||
|
||||
return cidr, err
|
||||
}
|
||||
|
||||
func schemaSubjectsToACL(subjectRules [][]string) (subjects []AccessControlSubjects) {
|
||||
for _, subjectRule := range subjectRules {
|
||||
subject := AccessControlSubjects{}
|
||||
|
||||
for _, subjectRuleItem := range subjectRule {
|
||||
subject.AddSubject(subjectRuleItem)
|
||||
}
|
||||
|
||||
if len(subject.Subjects) != 0 {
|
||||
subjects = append(subjects, subject)
|
||||
}
|
||||
}
|
||||
|
||||
return subjects
|
||||
}
|
||||
|
||||
func domainToPrefixSuffix(domain string) (prefix, suffix string) {
|
||||
parts := strings.Split(domain, ".")
|
||||
|
||||
if len(parts) == 1 {
|
||||
return "", parts[0]
|
||||
}
|
||||
|
||||
return parts[0], strings.Join(parts[1:], ".")
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
package authorization
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
||||
func TestShouldNotParseInvalidSubjects(t *testing.T) {
|
||||
subjectsSchema := [][]string{{"groups:z"}, {"group:z", "users:b"}}
|
||||
subjectsACL := schemaSubjectsToACL(subjectsSchema)
|
||||
|
||||
require.Len(t, subjectsACL, 1)
|
||||
|
||||
require.Len(t, subjectsACL[0].Subjects, 1)
|
||||
|
||||
assert.True(t, subjectsACL[0].IsMatch(Subject{Username: "a", Groups: []string{"z"}}))
|
||||
}
|
||||
|
||||
func TestShouldSplitDomainCorrectly(t *testing.T) {
|
||||
prefix, suffix := domainToPrefixSuffix("apple.example.com")
|
||||
|
||||
assert.Equal(t, "apple", prefix)
|
||||
assert.Equal(t, "example.com", suffix)
|
||||
|
||||
prefix, suffix = domainToPrefixSuffix("example")
|
||||
|
||||
assert.Equal(t, "", prefix)
|
||||
assert.Equal(t, "example", suffix)
|
||||
|
||||
prefix, suffix = domainToPrefixSuffix("example.com")
|
||||
|
||||
assert.Equal(t, "example", prefix)
|
||||
assert.Equal(t, "com", suffix)
|
||||
}
|
||||
|
||||
func TestShouldParseRuleNetworks(t *testing.T) {
|
||||
schemaNetworks := []schema.ACLNetwork{
|
||||
{
|
||||
Name: "desktop",
|
||||
Networks: []string{
|
||||
"10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lan",
|
||||
Networks: []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, firstNetwork, err := net.ParseCIDR("192.168.1.20/32")
|
||||
require.NoError(t, err)
|
||||
|
||||
networksMap, networksCacheMap := parseSchemaNetworks(schemaNetworks)
|
||||
|
||||
assert.Len(t, networksCacheMap, 5)
|
||||
|
||||
networks := []string{"192.168.1.20", "lan"}
|
||||
|
||||
acl := schemaNetworksToACL(networks, networksMap, networksCacheMap)
|
||||
|
||||
assert.Len(t, networksCacheMap, 7)
|
||||
|
||||
require.Len(t, acl, 4)
|
||||
assert.Equal(t, firstNetwork, acl[0])
|
||||
assert.Equal(t, networksMap["lan"][0], acl[1])
|
||||
assert.Equal(t, networksMap["lan"][1], acl[2])
|
||||
assert.Equal(t, networksMap["lan"][2], acl[3])
|
||||
|
||||
// Check they are the same memory address.
|
||||
assert.True(t, networksMap["lan"][0] == acl[1])
|
||||
assert.True(t, networksMap["lan"][1] == acl[2])
|
||||
assert.True(t, networksMap["lan"][2] == acl[3])
|
||||
|
||||
assert.False(t, firstNetwork == acl[0])
|
||||
}
|
||||
|
||||
func TestShouldParseACLNetworks(t *testing.T) {
|
||||
schemaNetworks := []schema.ACLNetwork{
|
||||
{
|
||||
Name: "test",
|
||||
Networks: []string{
|
||||
"10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "second",
|
||||
Networks: []string{
|
||||
"10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicate",
|
||||
Networks: []string{
|
||||
"10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicate",
|
||||
Networks: []string{
|
||||
"10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ipv6",
|
||||
Networks: []string{
|
||||
"fec0::1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ipv6net",
|
||||
Networks: []string{
|
||||
"fec0::1/64",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "net",
|
||||
Networks: []string{
|
||||
"10.0.0.0/8",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "badnet",
|
||||
Networks: []string{
|
||||
"bad/8",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, firstNetwork, err := net.ParseCIDR("10.0.0.1/32")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, secondNetwork, err := net.ParseCIDR("10.0.0.0/8")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, thirdNetwork, err := net.ParseCIDR("fec0::1/64")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, fourthNetwork, err := net.ParseCIDR("fec0::1/128")
|
||||
require.NoError(t, err)
|
||||
|
||||
networksMap, networksCacheMap := parseSchemaNetworks(schemaNetworks)
|
||||
|
||||
require.Len(t, networksMap, 6)
|
||||
require.Contains(t, networksMap, "test")
|
||||
require.Contains(t, networksMap, "second")
|
||||
require.Contains(t, networksMap, "duplicate")
|
||||
require.Contains(t, networksMap, "ipv6")
|
||||
require.Contains(t, networksMap, "ipv6net")
|
||||
require.Contains(t, networksMap, "net")
|
||||
require.Len(t, networksMap["test"], 1)
|
||||
|
||||
require.Len(t, networksCacheMap, 7)
|
||||
require.Contains(t, networksCacheMap, "10.0.0.1")
|
||||
require.Contains(t, networksCacheMap, "10.0.0.1/32")
|
||||
require.Contains(t, networksCacheMap, "10.0.0.1/32")
|
||||
require.Contains(t, networksCacheMap, "10.0.0.0/8")
|
||||
require.Contains(t, networksCacheMap, "fec0::1")
|
||||
require.Contains(t, networksCacheMap, "fec0::1/128")
|
||||
require.Contains(t, networksCacheMap, "fec0::1/64")
|
||||
|
||||
assert.Equal(t, firstNetwork, networksMap["test"][0])
|
||||
assert.Equal(t, secondNetwork, networksMap["net"][0])
|
||||
assert.Equal(t, thirdNetwork, networksMap["ipv6net"][0])
|
||||
assert.Equal(t, fourthNetwork, networksMap["ipv6"][0])
|
||||
|
||||
assert.Equal(t, firstNetwork, networksCacheMap["10.0.0.1"])
|
||||
assert.Equal(t, firstNetwork, networksCacheMap["10.0.0.1/32"])
|
||||
|
||||
assert.Equal(t, secondNetwork, networksCacheMap["10.0.0.0/8"])
|
||||
|
||||
assert.Equal(t, thirdNetwork, networksCacheMap["fec0::1/64"])
|
||||
|
||||
assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1"])
|
||||
assert.Equal(t, fourthNetwork, networksCacheMap["fec0::1/128"])
|
||||
}
|
|
@ -9,7 +9,7 @@ type AccessControlConfiguration struct {
|
|||
|
||||
// ACLNetwork represents one ACL network group entry; "weak" coerces a single value into slice.
|
||||
type ACLNetwork struct {
|
||||
Name []string `mapstructure:"name,weak"`
|
||||
Name string `mapstructure:"name"`
|
||||
Networks []string `mapstructure:"networks"`
|
||||
}
|
||||
|
||||
|
@ -20,16 +20,17 @@ type ACLRule struct {
|
|||
Subjects [][]string `mapstructure:"subject,weak"`
|
||||
Networks []string `mapstructure:"networks"`
|
||||
Resources []string `mapstructure:"resources"`
|
||||
Methods []string `mapstructure:"methods"`
|
||||
}
|
||||
|
||||
// DefaultACLNetwork represents the default configuration related to access control network group configuration.
|
||||
var DefaultACLNetwork = []ACLNetwork{
|
||||
{
|
||||
Name: []string{"localhost"},
|
||||
Name: "localhost",
|
||||
Networks: []string{"127.0.0.1"},
|
||||
},
|
||||
{
|
||||
Name: []string{"internal"},
|
||||
Name: "internal",
|
||||
Networks: []string{"10.0.0.0/8"},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -11,25 +11,25 @@ import (
|
|||
)
|
||||
|
||||
// IsPolicyValid check if policy is valid.
|
||||
func IsPolicyValid(policy string) bool {
|
||||
return policy == denyPolicy || policy == "one_factor" || policy == "two_factor" || policy == "bypass"
|
||||
func IsPolicyValid(policy string) (isValid bool) {
|
||||
return policy == denyPolicy || policy == "one_factor" || policy == "two_factor" || policy == bypassPolicy
|
||||
}
|
||||
|
||||
// IsResourceValid check if a resource is valid.
|
||||
func IsResourceValid(resource string) error {
|
||||
_, err := regexp.Compile(resource)
|
||||
func IsResourceValid(resource string) (err error) {
|
||||
_, err = regexp.Compile(resource)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsSubjectValid check if a subject is valid.
|
||||
func IsSubjectValid(subject string) bool {
|
||||
func IsSubjectValid(subject string) (isValid bool) {
|
||||
return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:")
|
||||
}
|
||||
|
||||
// IsNetworkGroupValid check if a network group is valid.
|
||||
func IsNetworkGroupValid(configuration schema.AccessControlConfiguration, network string) bool {
|
||||
for _, networks := range configuration.Networks {
|
||||
if !utils.IsStringInSlice(network, networks.Name) {
|
||||
if network != networks.Name {
|
||||
continue
|
||||
} else {
|
||||
return true
|
||||
|
@ -40,7 +40,7 @@ func IsNetworkGroupValid(configuration schema.AccessControlConfiguration, networ
|
|||
}
|
||||
|
||||
// IsNetworkValid check if a network is valid.
|
||||
func IsNetworkValid(network string) bool {
|
||||
func IsNetworkValid(network string) (isValid bool) {
|
||||
if net.ParseIP(network) == nil {
|
||||
_, _, err := net.ParseCIDR(network)
|
||||
return err == nil
|
||||
|
@ -77,6 +77,21 @@ func ValidateRules(configuration schema.AccessControlConfiguration, validator *s
|
|||
validator.Push(fmt.Errorf("Policy [%s] for domain: %s is invalid, a policy must either be 'deny', 'two_factor', 'one_factor' or 'bypass'", r.Policy, r.Domains))
|
||||
}
|
||||
|
||||
validateNetworks(r, configuration, validator)
|
||||
|
||||
validateResources(r, validator)
|
||||
|
||||
validateSubjects(r, validator)
|
||||
|
||||
validateMethods(r, validator)
|
||||
|
||||
if r.Policy == bypassPolicy && len(r.Subjects) != 0 {
|
||||
validator.Push(fmt.Errorf(errAccessControlInvalidPolicyWithSubjects, r.Domains, r.Subjects))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateNetworks(r schema.ACLRule, configuration schema.AccessControlConfiguration, validator *schema.StructValidator) {
|
||||
for _, network := range r.Networks {
|
||||
if !IsNetworkValid(network) {
|
||||
if !IsNetworkGroupValid(configuration, network) {
|
||||
|
@ -84,19 +99,30 @@ func ValidateRules(configuration schema.AccessControlConfiguration, validator *s
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateResources(r schema.ACLRule, validator *schema.StructValidator) {
|
||||
for _, resource := range r.Resources {
|
||||
if err := IsResourceValid(resource); err != nil {
|
||||
validator.Push(fmt.Errorf("Resource %s for domain: %s is invalid, %s", r.Resources, r.Domains, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateSubjects(r schema.ACLRule, validator *schema.StructValidator) {
|
||||
for _, subjectRule := range r.Subjects {
|
||||
for _, subject := range subjectRule {
|
||||
if !IsSubjectValid(subject) {
|
||||
validator.Push(fmt.Errorf("Subject %s for domain: %s must start with 'user:' or 'group:'", subjectRule, r.Domains))
|
||||
}
|
||||
validator.Push(fmt.Errorf("Subject %s for domain: %s is invalid, must start with 'user:' or 'group:'", subjectRule, r.Domains))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateMethods(r schema.ACLRule, validator *schema.StructValidator) {
|
||||
for _, method := range r.Methods {
|
||||
if !utils.IsStringInSliceFold(method, validRequestMethods) {
|
||||
validator.Push(fmt.Errorf("Method %s for domain: %s is invalid, must be one of the following methods: %s", method, r.Domains, strings.Join(validRequestMethods, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
@ -42,7 +43,7 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidDefaultPolicy() {
|
|||
func (suite *AccessControl) TestShouldRaiseErrorInvalidNetworkGroupNetwork() {
|
||||
suite.configuration.Networks = []schema.ACLNetwork{
|
||||
{
|
||||
Name: []string{"internal"},
|
||||
Name: "internal",
|
||||
Networks: []string{"abc.def.ghi.jkl"},
|
||||
},
|
||||
}
|
||||
|
@ -52,7 +53,7 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidNetworkGroupNetwork() {
|
|||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Require().Len(suite.validator.Errors(), 1)
|
||||
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Network [abc.def.ghi.jkl] from network group: [internal] must be a valid IP or CIDR")
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Network [abc.def.ghi.jkl] from network group: internal must be a valid IP or CIDR")
|
||||
}
|
||||
|
||||
func (suite *AccessControl) TestShouldRaiseErrorNoRulesDefined() {
|
||||
|
@ -100,6 +101,23 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidNetwork() {
|
|||
suite.Assert().EqualError(suite.validator.Errors()[0], "Network [abc.def.ghi.jkl/32] for domain: [public.example.com] is not a valid network or network group")
|
||||
}
|
||||
|
||||
func (suite *AccessControl) TestShouldRaiseErrorInvalidMethod() {
|
||||
suite.configuration.Rules = []schema.ACLRule{
|
||||
{
|
||||
Domains: []string{"public.example.com"},
|
||||
Policy: "bypass",
|
||||
Methods: []string{"GET", "HOP"},
|
||||
},
|
||||
}
|
||||
|
||||
ValidateRules(suite.configuration, suite.validator)
|
||||
|
||||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Require().Len(suite.validator.Errors(), 1)
|
||||
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Method HOP for domain: [public.example.com] is invalid, must be one of the following methods: GET, HEAD, POST, PUT, PATCH, DELETE, TRACE, CONNECT, OPTIONS")
|
||||
}
|
||||
|
||||
func (suite *AccessControl) TestShouldRaiseErrorInvalidResource() {
|
||||
suite.configuration.Rules = []schema.ACLRule{
|
||||
{
|
||||
|
@ -118,20 +136,23 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidResource() {
|
|||
}
|
||||
|
||||
func (suite *AccessControl) TestShouldRaiseErrorInvalidSubject() {
|
||||
domains := []string{"public.example.com"}
|
||||
subjects := [][]string{{"invalid"}}
|
||||
suite.configuration.Rules = []schema.ACLRule{
|
||||
{
|
||||
Domains: []string{"public.example.com"},
|
||||
Domains: domains,
|
||||
Policy: "bypass",
|
||||
Subjects: [][]string{{"invalid"}},
|
||||
Subjects: subjects,
|
||||
},
|
||||
}
|
||||
|
||||
ValidateRules(suite.configuration, suite.validator)
|
||||
|
||||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Require().Len(suite.validator.Errors(), 1)
|
||||
suite.Require().Len(suite.validator.Warnings(), 0)
|
||||
suite.Require().Len(suite.validator.Errors(), 2)
|
||||
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Subject [invalid] for domain: [public.example.com] must start with 'user:' or 'group:'")
|
||||
suite.Assert().EqualError(suite.validator.Errors()[0], "Subject [invalid] for domain: [public.example.com] is invalid, must start with 'user:' or 'group:'")
|
||||
suite.Assert().EqualError(suite.validator.Errors()[1], fmt.Sprintf(errAccessControlInvalidPolicyWithSubjects, domains, subjects))
|
||||
}
|
||||
|
||||
func TestAccessControl(t *testing.T) {
|
||||
|
|
|
@ -195,10 +195,6 @@ func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationB
|
|||
} else if !strings.HasPrefix(configuration.GroupsFilter, "(") || !strings.HasSuffix(configuration.GroupsFilter, ")") {
|
||||
validator.Push(errors.New("The groups filter should contain enclosing parenthesis. For instance cn={input} should be (cn={input})"))
|
||||
}
|
||||
|
||||
if configuration.UsernameAttribute == "" {
|
||||
validator.Push(errors.New("Please provide a username attribute with `username_attribute`"))
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaultImplementationActiveDirectoryLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationBackendConfiguration) {
|
||||
|
|
|
@ -214,6 +214,18 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldValidateCompleteConfigura
|
|||
suite.Assert().False(suite.validator.HasErrors())
|
||||
}
|
||||
|
||||
func (suite *LdapAuthenticationBackendSuite) TestShouldValidateDefaultImplementationAndUsernameAttribute() {
|
||||
suite.configuration.Ldap.Implementation = ""
|
||||
suite.configuration.Ldap.UsernameAttribute = ""
|
||||
ValidateAuthenticationBackend(&suite.configuration, suite.validator)
|
||||
|
||||
suite.Assert().Equal(schema.LDAPImplementationCustom, suite.configuration.Ldap.Implementation)
|
||||
|
||||
suite.Assert().Equal(suite.configuration.Ldap.UsernameAttribute, schema.DefaultLDAPAuthenticationBackendConfiguration.UsernameAttribute)
|
||||
suite.Assert().False(suite.validator.HasWarnings())
|
||||
suite.Assert().False(suite.validator.HasErrors())
|
||||
}
|
||||
|
||||
func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseErrorWhenImplementationIsInvalidMSAD() {
|
||||
suite.configuration.Ldap.Implementation = "masd"
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package validator
|
||||
|
||||
var validRequestMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS"}
|
||||
|
||||
var validKeys = []string{
|
||||
// Root Keys.
|
||||
"host",
|
||||
|
@ -170,6 +172,7 @@ var specificErrorKeys = map[string]string{
|
|||
}
|
||||
|
||||
const denyPolicy = "deny"
|
||||
const bypassPolicy = "bypass"
|
||||
|
||||
const argon2id = "argon2id"
|
||||
const sha512 = "sha512"
|
||||
|
@ -187,3 +190,5 @@ const testLDAPUser = "user"
|
|||
const testModeDisabled = "disable"
|
||||
const testTLSCert = "/tmp/cert.pem"
|
||||
const testTLSKey = "/tmp/key.pem"
|
||||
|
||||
const errAccessControlInvalidPolicyWithSubjects = "Policy [bypass] for domain %s with subjects %s is invalid. It is not supported to configure both policy bypass and subjects. For more information see: https://www.authelia.com/docs/configuration/access-control.html#combining-subjects-and-the-bypass-policy"
|
||||
|
|
|
@ -38,8 +38,22 @@ func TestShouldRaiseErrorWhenFindTimeLessThanBanTime(t *testing.T) {
|
|||
config := newDefaultRegulationConfig()
|
||||
config.FindTime = "1m"
|
||||
config.BanTime = "10s"
|
||||
|
||||
ValidateRegulation(&config, validator)
|
||||
|
||||
assert.Len(t, validator.Errors(), 1)
|
||||
assert.EqualError(t, validator.Errors()[0], "find_time cannot be greater than ban_time")
|
||||
}
|
||||
|
||||
func TestShouldRaiseErrorOnBadDurationStrings(t *testing.T) {
|
||||
validator := schema.NewStructValidator()
|
||||
config := newDefaultRegulationConfig()
|
||||
config.FindTime = "a year"
|
||||
config.BanTime = "forever"
|
||||
|
||||
ValidateRegulation(&config, validator)
|
||||
|
||||
assert.Len(t, validator.Errors(), 2)
|
||||
assert.EqualError(t, validator.Errors()[0], "Error occurred parsing regulation find_time string: Could not convert the input string of a year into a duration")
|
||||
assert.EqualError(t, validator.Errors()[1], "Error occurred parsing regulation ban_time string: Could not convert the input string of forever into a duration")
|
||||
}
|
||||
|
|
|
@ -188,6 +188,6 @@ func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middleware
|
|||
|
||||
successful = true
|
||||
|
||||
Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
|
||||
Handle1FAResponse(ctx, bodyJSON.TargetURL, bodyJSON.RequestMethod, userSession.Username, userSession.Groups)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -210,6 +210,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
@ -253,6 +254,7 @@ func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSess
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
@ -323,6 +325,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTarget
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
@ -341,6 +344,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUns
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": false,
|
||||
"targetURL": "http://notsafe.local"
|
||||
}`)
|
||||
|
@ -362,6 +366,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedA
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
|
||||
|
@ -392,6 +397,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvi
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test",
|
||||
"password": "hello",
|
||||
"requestMethod": "GET",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
|
||||
|
|
|
@ -98,12 +98,14 @@ func parseBasicAuth(header, auth string) (username, password string, err error)
|
|||
|
||||
// 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 string, userGroups []string, clientIP net.IP, method []byte, authLevel authentication.Level) authorizationMatching {
|
||||
level := authorizer.GetRequiredLevel(
|
||||
authorization.Subject{
|
||||
Username: username,
|
||||
Groups: userGroups,
|
||||
IP: clientIP,
|
||||
}, targetURL)
|
||||
},
|
||||
authorization.NewObjectRaw(&targetURL, method))
|
||||
|
||||
switch {
|
||||
case level == authorization.Bypass:
|
||||
|
@ -233,7 +235,7 @@ func verifySessionCookie(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userS
|
|||
return userSession.Username, userSession.DisplayName, userSession.Groups, userSession.Emails, userSession.AuthenticationLevel, nil
|
||||
}
|
||||
|
||||
func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, isBasicAuth bool, username string) {
|
||||
func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, isBasicAuth bool, username string, method []byte) {
|
||||
if isBasicAuth {
|
||||
ctx.Logger.Infof("Access to %s is not authorized to user %s, sending 401 response with basic auth header", targetURL.String(), username)
|
||||
ctx.ReplyUnauthorized()
|
||||
|
@ -246,17 +248,28 @@ func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, is
|
|||
// endpoint to provide the URL of the login portal. The target URL of the user
|
||||
// is computed from X-Forwarded-* 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")
|
||||
rm := string(method)
|
||||
|
||||
friendlyMethod := "unknown"
|
||||
|
||||
if rm != "" {
|
||||
friendlyMethod = rm
|
||||
}
|
||||
|
||||
ctx.Logger.Infof("Access to %s is not authorized to user %s, redirecting to %s", targetURL.String(), username, redirectionURL)
|
||||
if rd != "" {
|
||||
redirectionURL := ""
|
||||
|
||||
if rm != "" {
|
||||
redirectionURL = fmt.Sprintf("%s?rd=%s&rm=%s", rd, url.QueryEscape(targetURL.String()), rm)
|
||||
} else {
|
||||
redirectionURL = fmt.Sprintf("%s?rd=%s", rd, url.QueryEscape(targetURL.String()))
|
||||
}
|
||||
|
||||
ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, redirecting to %s", targetURL.String(), friendlyMethod, 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.Logger.Infof("Access to %s (method %s) is not authorized to user %s, sending 401 response", targetURL.String(), friendlyMethod, username)
|
||||
ctx.ReplyUnauthorized()
|
||||
}
|
||||
}
|
||||
|
@ -475,6 +488,8 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques
|
|||
|
||||
isBasicAuth, username, name, groups, emails, authLevel, err := verifyAuth(ctx, targetURL, refreshProfile, refreshProfileInterval)
|
||||
|
||||
method := ctx.XForwardedMethod()
|
||||
|
||||
if err != nil {
|
||||
ctx.Logger.Error(fmt.Sprintf("Error caught when verifying user authorization: %s", err))
|
||||
|
||||
|
@ -483,20 +498,20 @@ func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.Reques
|
|||
return
|
||||
}
|
||||
|
||||
handleUnauthorized(ctx, targetURL, isBasicAuth, username)
|
||||
handleUnauthorized(ctx, targetURL, isBasicAuth, username, method)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
authorization := isTargetURLAuthorized(ctx.Providers.Authorizer, *targetURL, username,
|
||||
groups, ctx.RemoteIP(), authLevel)
|
||||
authorized := isTargetURLAuthorized(ctx.Providers.Authorizer, *targetURL, username,
|
||||
groups, ctx.RemoteIP(), method, authLevel)
|
||||
|
||||
switch authorization {
|
||||
switch authorized {
|
||||
case Forbidden:
|
||||
ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username)
|
||||
ctx.ReplyForbidden()
|
||||
case NotAuthorized:
|
||||
handleUnauthorized(ctx, targetURL, isBasicAuth, username)
|
||||
handleUnauthorized(ctx, targetURL, isBasicAuth, username, method)
|
||||
case Authorized:
|
||||
setForwardedHeaders(&ctx.Response.Header, username, name, groups, emails)
|
||||
}
|
||||
|
|
|
@ -196,7 +196,7 @@ func TestShouldCheckAuthorizationMatching(t *testing.T) {
|
|||
username = testUsername
|
||||
}
|
||||
|
||||
matching := isTargetURLAuthorized(authorizer, *url, username, []string{}, net.ParseIP("127.0.0.1"), rule.AuthLevel)
|
||||
matching := isTargetURLAuthorized(authorizer, *url, username, []string{}, net.ParseIP("127.0.0.1"), []byte("GET"), rule.AuthLevel)
|
||||
assert.Equal(t, rule.ExpectedMatching, matching, "policy=%s, authLevel=%v, expected=%v, actual=%v",
|
||||
rule.Policy, rule.AuthLevel, rule.ExpectedMatching, matching)
|
||||
}
|
||||
|
@ -762,10 +762,11 @@ func TestShouldRedirectWhenSessionInactiveForTooLongAndRDParamProvided(t *testin
|
|||
|
||||
mock.Ctx.QueryArgs().Add("rd", "https://login.example.com")
|
||||
mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com")
|
||||
mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET")
|
||||
|
||||
VerifyGet(verifyGetCfg)(mock.Ctx)
|
||||
|
||||
assert.Equal(t, "Found. Redirecting to https://login.example.com?rd=https%3A%2F%2Ftwo-factor.example.com",
|
||||
assert.Equal(t, "Found. Redirecting to https://login.example.com?rd=https%3A%2F%2Ftwo-factor.example.com&rm=GET",
|
||||
string(mock.Ctx.Response.Body()))
|
||||
assert.Equal(t, 302, mock.Ctx.Response.StatusCode())
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
// Handle1FAResponse handle the redirection upon 1FA authentication.
|
||||
func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI string, username string, groups []string) {
|
||||
func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod string, username string, groups []string) {
|
||||
if targetURI == "" {
|
||||
if !ctx.Providers.Authorizer.IsSecondFactorEnabled() && ctx.Configuration.DefaultRedirectionURL != "" {
|
||||
err := ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
|
||||
|
@ -32,11 +32,13 @@ func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI string, username
|
|||
return
|
||||
}
|
||||
|
||||
requiredLevel := ctx.Providers.Authorizer.GetRequiredLevel(authorization.Subject{
|
||||
requiredLevel := ctx.Providers.Authorizer.GetRequiredLevel(
|
||||
authorization.Subject{
|
||||
Username: username,
|
||||
Groups: groups,
|
||||
IP: ctx.RemoteIP(),
|
||||
}, *targetURL)
|
||||
},
|
||||
authorization.NewObject(targetURL, requestMethod))
|
||||
|
||||
ctx.Logger.Debugf("Required level for the URL %s is %d", targetURI, requiredLevel)
|
||||
|
||||
|
|
|
@ -42,14 +42,15 @@ type signDuoRequestBody struct {
|
|||
TargetURL string `json:"targetURL"`
|
||||
}
|
||||
|
||||
// firstFactorBody represents the JSON body received by the endpoint.
|
||||
// firstFactorRequestBody represents the JSON body received by the endpoint.
|
||||
type firstFactorRequestBody struct {
|
||||
Username string `json:"username" valid:"required"`
|
||||
Password string `json:"password" valid:"required"`
|
||||
TargetURL string `json:"targetURL"`
|
||||
// Cannot require this field because of https://github.com/asaskevich/govalidator/pull/329
|
||||
// TODO(c.michaud): add required validation once the above PR is merged.
|
||||
RequestMethod string `json:"requestMethod"`
|
||||
KeepMeLoggedIn *bool `json:"keepMeLoggedIn"`
|
||||
// KeepMeLoggedIn: Cannot require this field because of https://github.com/asaskevich/govalidator/pull/329
|
||||
// TODO(c.michaud): add required validation once the above PR is merged.
|
||||
}
|
||||
|
||||
// redirectResponse represent the response sent by the first factor endpoint
|
||||
|
|
|
@ -87,22 +87,27 @@ func (c *AutheliaCtx) ReplyForbidden() {
|
|||
c.RequestCtx.Error(fasthttp.StatusMessage(fasthttp.StatusForbidden), fasthttp.StatusForbidden)
|
||||
}
|
||||
|
||||
// XForwardedProto return the content of the header X-Forwarded-Proto.
|
||||
// XForwardedProto return the content of the X-Forwarded-Proto header.
|
||||
func (c *AutheliaCtx) XForwardedProto() []byte {
|
||||
return c.RequestCtx.Request.Header.Peek(xForwardedProtoHeader)
|
||||
}
|
||||
|
||||
// XForwardedHost return the content of the header X-Forwarded-Host.
|
||||
// XForwardedMethod return the content of the X-Forwarded-Method header.
|
||||
func (c *AutheliaCtx) XForwardedMethod() []byte {
|
||||
return c.RequestCtx.Request.Header.Peek(xForwardedMethodHeader)
|
||||
}
|
||||
|
||||
// XForwardedHost return the content of the X-Forwarded-Host header.
|
||||
func (c *AutheliaCtx) XForwardedHost() []byte {
|
||||
return c.RequestCtx.Request.Header.Peek(xForwardedHostHeader)
|
||||
}
|
||||
|
||||
// XForwardedURI return the content of the header X-Forwarded-URI.
|
||||
// XForwardedURI return the content of the X-Forwarded-URI header.
|
||||
func (c *AutheliaCtx) XForwardedURI() []byte {
|
||||
return c.RequestCtx.Request.Header.Peek(xForwardedURIHeader)
|
||||
}
|
||||
|
||||
// XOriginalURL return the content of the header X-Original-URL.
|
||||
// XOriginalURL return the content of the X-Original-URL header.
|
||||
func (c *AutheliaCtx) XOriginalURL() []byte {
|
||||
return c.RequestCtx.Request.Header.Peek(xOriginalURLHeader)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package middlewares
|
|||
const jwtIssuer = "Authelia"
|
||||
|
||||
const xForwardedProtoHeader = "X-Forwarded-Proto"
|
||||
const xForwardedMethodHeader = "X-Forwarded-Method"
|
||||
const xForwardedHostHeader = "X-Forwarded-Host"
|
||||
const xForwardedURIHeader = "X-Forwarded-URI"
|
||||
|
||||
|
|
|
@ -35,6 +35,11 @@ access_control:
|
|||
- domain: public.example.com
|
||||
policy: bypass
|
||||
|
||||
- domain: secure.example.com
|
||||
policy: bypass
|
||||
methods:
|
||||
- OPTIONS
|
||||
|
||||
- domain: secure.example.com
|
||||
policy: two_factor
|
||||
|
||||
|
|
|
@ -28,6 +28,16 @@ frontend fe_http
|
|||
http-request set-var(req.scheme) str(https) if { ssl_fc }
|
||||
http-request set-var(req.scheme) str(http) if !{ ssl_fc }
|
||||
http-request set-var(req.questionmark) str(?) if { query -m found }
|
||||
http-request set-var(req.method) str(CONNECT) if { method CONNECT }
|
||||
http-request set-var(req.method) str(GET) if { method GET }
|
||||
http-request set-var(req.method) str(HEAD) if { method HEAD }
|
||||
http-request set-var(req.method) str(OPTIONS) if { method OPTIONS }
|
||||
http-request set-var(req.method) str(POST) if { method POST }
|
||||
http-request set-var(req.method) str(TRACE) if { method TRACE }
|
||||
http-request set-var(req.method) str(PUT) if { method PUT }
|
||||
http-request set-var(req.method) str(PATCH) if { method PATCH }
|
||||
http-request set-var(req.method) str(DELETE) if { method DELETE }
|
||||
http-request set-header X-Forwarded-Method %[var(req.method)]
|
||||
|
||||
http-request set-header X-Real-IP %[src]
|
||||
http-request set-header X-Forwarded-Proto %[var(req.scheme)]
|
||||
|
|
|
@ -152,10 +152,10 @@ http {
|
|||
# See https://expressjs.com/en/guide/behind-proxies.html
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
|
||||
proxy_set_header X-Forwarded-Method $request_method;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-URI $request_uri;
|
||||
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Authelia can receive Proxy-Authorization to authenticate however most of the clients
|
||||
|
|
|
@ -103,6 +103,31 @@ func NewStandaloneSuite() *StandaloneSuite {
|
|||
return &StandaloneSuite{}
|
||||
}
|
||||
|
||||
func (s *StandaloneSuite) TestShouldRespectMethodsACL() {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/verify?rd=%s", AutheliaBaseURL, GetLoginBaseURL()), nil)
|
||||
s.Assert().NoError(err)
|
||||
req.Header.Set("X-Forwarded-Method", "GET")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", fmt.Sprintf("secure.%s", BaseDomain))
|
||||
req.Header.Set("X-Forwarded-URI", "/")
|
||||
|
||||
client := NewHTTPClient()
|
||||
res, err := client.Do(req)
|
||||
s.Assert().NoError(err)
|
||||
s.Assert().Equal(res.StatusCode, 302)
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
s.Assert().NoError(err)
|
||||
|
||||
urlEncodedAdminURL := url.QueryEscape(SecureBaseURL + "/")
|
||||
s.Assert().Equal(fmt.Sprintf("Found. Redirecting to %s?rd=%s&rm=GET", GetLoginBaseURL(), urlEncodedAdminURL), string(body))
|
||||
|
||||
req.Header.Set("X-Forwarded-Method", "OPTIONS")
|
||||
|
||||
res, err = client.Do(req)
|
||||
s.Assert().NoError(err)
|
||||
s.Assert().Equal(res.StatusCode, 200)
|
||||
}
|
||||
|
||||
// Standard case using nginx.
|
||||
func (s *StandaloneSuite) TestShouldVerifyAPIVerifyUnauthorize() {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/verify", AutheliaBaseURL), nil)
|
||||
|
|
|
@ -32,7 +32,7 @@ func NewTLSConfig(config *schema.TLSConfig, defaultMinVersion uint16, certPool *
|
|||
func NewX509CertPool(directory string, config *schema.Configuration) (certPool *x509.CertPool, errors []error, nonFatalErrors []error) {
|
||||
certPool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
nonFatalErrors = append(nonFatalErrors, fmt.Errorf("could not load system certificate pool which may result in untruested certificate issues: %v", err))
|
||||
nonFatalErrors = append(nonFatalErrors, fmt.Errorf("could not load system certificate pool which may result in untrusted certificate issues: %v", err))
|
||||
certPool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
|
|
|
@ -18,9 +18,9 @@ func IsStringAlphaNumeric(input string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// IsStringInSlice checks if a single string is in an array of strings.
|
||||
func IsStringInSlice(a string, list []string) (inSlice bool) {
|
||||
for _, b := range list {
|
||||
// IsStringInSlice checks if a single string is in a slice of strings.
|
||||
func IsStringInSlice(a string, slice []string) (inSlice bool) {
|
||||
for _, b := range slice {
|
||||
if b == a {
|
||||
return true
|
||||
}
|
||||
|
@ -29,6 +29,17 @@ func IsStringInSlice(a string, list []string) (inSlice bool) {
|
|||
return false
|
||||
}
|
||||
|
||||
// IsStringInSliceFold checks if a single string is in a slice of strings but uses strings.EqualFold to compare them.
|
||||
func IsStringInSliceFold(a string, slice []string) (inSlice bool) {
|
||||
for _, b := range slice {
|
||||
if strings.EqualFold(b, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsStringInSliceContains checks if a single string is in an array of strings.
|
||||
func IsStringInSliceContains(a string, list []string) (inSlice bool) {
|
||||
for _, b := range list {
|
||||
|
|
|
@ -9,7 +9,9 @@ import (
|
|||
|
||||
func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) {
|
||||
input := testStringInput
|
||||
|
||||
arrayOfStrings := SliceString(input, 4)
|
||||
|
||||
assert.Equal(t, len(arrayOfStrings), 3)
|
||||
assert.Equal(t, "abcd", arrayOfStrings[0])
|
||||
assert.Equal(t, "efgh", arrayOfStrings[1])
|
||||
|
@ -18,7 +20,9 @@ func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) {
|
|||
|
||||
func TestShouldSplitIntoEvenStringsOfOne(t *testing.T) {
|
||||
input := testStringInput
|
||||
|
||||
arrayOfStrings := SliceString(input, 1)
|
||||
|
||||
assert.Equal(t, 12, len(arrayOfStrings))
|
||||
assert.Equal(t, "a", arrayOfStrings[0])
|
||||
assert.Equal(t, "b", arrayOfStrings[1])
|
||||
|
@ -29,7 +33,9 @@ func TestShouldSplitIntoEvenStringsOfOne(t *testing.T) {
|
|||
|
||||
func TestShouldSplitIntoUnevenStringsOfFour(t *testing.T) {
|
||||
input := testStringInput + "m"
|
||||
|
||||
arrayOfStrings := SliceString(input, 4)
|
||||
|
||||
assert.Equal(t, len(arrayOfStrings), 4)
|
||||
assert.Equal(t, "abcd", arrayOfStrings[0])
|
||||
assert.Equal(t, "efgh", arrayOfStrings[1])
|
||||
|
@ -40,7 +46,9 @@ func TestShouldSplitIntoUnevenStringsOfFour(t *testing.T) {
|
|||
func TestShouldFindSliceDifferencesDelta(t *testing.T) {
|
||||
before := []string{"abc", "onetwothree"}
|
||||
after := []string{"abc", "xyz"}
|
||||
|
||||
added, removed := StringSlicesDelta(before, after)
|
||||
|
||||
require.Len(t, added, 1)
|
||||
require.Len(t, removed, 1)
|
||||
assert.Equal(t, "onetwothree", removed[0])
|
||||
|
@ -50,7 +58,9 @@ func TestShouldFindSliceDifferencesDelta(t *testing.T) {
|
|||
func TestShouldNotFindSliceDifferencesDelta(t *testing.T) {
|
||||
before := []string{"abc", "onetwothree"}
|
||||
after := []string{"abc", "onetwothree"}
|
||||
|
||||
added, removed := StringSlicesDelta(before, after)
|
||||
|
||||
require.Len(t, added, 0)
|
||||
require.Len(t, removed, 0)
|
||||
}
|
||||
|
@ -58,34 +68,52 @@ func TestShouldNotFindSliceDifferencesDelta(t *testing.T) {
|
|||
func TestShouldFindSliceDifferences(t *testing.T) {
|
||||
a := []string{"abc", "onetwothree"}
|
||||
b := []string{"abc", "xyz"}
|
||||
diff := IsStringSlicesDifferent(a, b)
|
||||
assert.True(t, diff)
|
||||
|
||||
assert.True(t, IsStringSlicesDifferent(a, b))
|
||||
}
|
||||
|
||||
func TestShouldNotFindSliceDifferences(t *testing.T) {
|
||||
a := []string{"abc", "onetwothree"}
|
||||
b := []string{"abc", "onetwothree"}
|
||||
diff := IsStringSlicesDifferent(a, b)
|
||||
assert.False(t, diff)
|
||||
|
||||
assert.False(t, IsStringSlicesDifferent(a, b))
|
||||
}
|
||||
|
||||
func TestShouldFindSliceDifferenceWhenDifferentLength(t *testing.T) {
|
||||
a := []string{"abc", "onetwothree"}
|
||||
b := []string{"abc", "onetwothree", "more"}
|
||||
diff := IsStringSlicesDifferent(a, b)
|
||||
assert.True(t, diff)
|
||||
|
||||
assert.True(t, IsStringSlicesDifferent(a, b))
|
||||
}
|
||||
|
||||
func TestShouldFindStringInSliceContains(t *testing.T) {
|
||||
a := "abc"
|
||||
b := []string{"abc", "onetwothree"}
|
||||
s := IsStringInSliceContains(a, b)
|
||||
assert.True(t, s)
|
||||
slice := []string{"abc", "onetwothree"}
|
||||
|
||||
assert.True(t, IsStringInSliceContains(a, slice))
|
||||
}
|
||||
|
||||
func TestShouldNotFindStringInSliceContains(t *testing.T) {
|
||||
a := "xyz"
|
||||
b := []string{"abc", "onetwothree"}
|
||||
s := IsStringInSliceContains(a, b)
|
||||
assert.False(t, s)
|
||||
slice := []string{"abc", "onetwothree"}
|
||||
|
||||
assert.False(t, IsStringInSliceContains(a, slice))
|
||||
}
|
||||
|
||||
func TestShouldFindStringInSliceFold(t *testing.T) {
|
||||
a := "xYz"
|
||||
b := "AbC"
|
||||
slice := []string{"XYz", "abc"}
|
||||
|
||||
assert.True(t, IsStringInSliceFold(a, slice))
|
||||
assert.True(t, IsStringInSliceFold(b, slice))
|
||||
}
|
||||
|
||||
func TestShouldNotFindStringInSliceFold(t *testing.T) {
|
||||
a := "xyZ"
|
||||
b := "ABc"
|
||||
slice := []string{"cba", "zyx"}
|
||||
|
||||
assert.False(t, IsStringInSliceFold(a, slice))
|
||||
assert.False(t, IsStringInSliceFold(b, slice))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import queryString from "query-string";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export function useRequestMethod() {
|
||||
const location = useLocation();
|
||||
const queryParams = queryString.parse(location.search);
|
||||
return queryParams && "rm" in queryParams ? (queryParams["rm"] as string) : undefined;
|
||||
}
|
|
@ -7,9 +7,16 @@ interface PostFirstFactorBody {
|
|||
password: string;
|
||||
keepMeLoggedIn: boolean;
|
||||
targetURL?: string;
|
||||
requestMethod?: string;
|
||||
}
|
||||
|
||||
export async function postFirstFactor(username: string, password: string, rememberMe: boolean, targetURL?: string) {
|
||||
export async function postFirstFactor(
|
||||
username: string,
|
||||
password: string,
|
||||
rememberMe: boolean,
|
||||
targetURL?: string,
|
||||
requestMethod?: string,
|
||||
) {
|
||||
const data: PostFirstFactorBody = {
|
||||
username,
|
||||
password,
|
||||
|
@ -19,6 +26,11 @@ export async function postFirstFactor(username: string, password: string, rememb
|
|||
if (targetURL) {
|
||||
data.targetURL = targetURL;
|
||||
}
|
||||
|
||||
if (requestMethod) {
|
||||
data.requestMethod = requestMethod;
|
||||
}
|
||||
|
||||
const res = await PostWithOptionalResponse<SignInResponse>(FirstFactorPath, data);
|
||||
return res ? res : ({} as SignInResponse);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useHistory } from "react-router";
|
|||
import FixedTextField from "../../../components/FixedTextField";
|
||||
import { useNotifications } from "../../../hooks/NotificationsContext";
|
||||
import { useRedirectionURL } from "../../../hooks/RedirectionURL";
|
||||
import { useRequestMethod } from "../../../hooks/RequestMethod";
|
||||
import LoginLayout from "../../../layouts/LoginLayout";
|
||||
import { ResetPasswordStep1Route } from "../../../Routes";
|
||||
import { postFirstFactor } from "../../../services/FirstFactor";
|
||||
|
@ -25,6 +26,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
const style = useStyles();
|
||||
const history = useHistory();
|
||||
const redirectionURL = useRedirectionURL();
|
||||
const requestMethod = useRequestMethod();
|
||||
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
|
@ -60,7 +62,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
|
||||
props.onAuthenticationStart();
|
||||
try {
|
||||
const res = await postFirstFactor(username, password, rememberMe, redirectionURL);
|
||||
const res = await postFirstFactor(username, password, rememberMe, redirectionURL, requestMethod);
|
||||
props.onAuthenticationSuccess(res ? res.redirect : undefined);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Switch, Route, Redirect, useHistory, useLocation } from "react-router";
|
|||
import { useConfiguration } from "../../hooks/Configuration";
|
||||
import { useNotifications } from "../../hooks/NotificationsContext";
|
||||
import { useRedirectionURL } from "../../hooks/RedirectionURL";
|
||||
import { useRequestMethod } from "../../hooks/RequestMethod";
|
||||
import { useAutheliaState } from "../../hooks/State";
|
||||
import { useUserPreferences as userUserInfo } from "../../hooks/UserInfo";
|
||||
import { SecondFactorMethod } from "../../models/Methods";
|
||||
|
@ -31,6 +32,7 @@ const LoginPortal = function (props: Props) {
|
|||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const redirectionURL = useRedirectionURL();
|
||||
const requestMethod = useRequestMethod();
|
||||
const { createErrorNotification } = useNotifications();
|
||||
const [firstFactorDisabled, setFirstFactorDisabled] = useState(true);
|
||||
|
||||
|
@ -84,7 +86,9 @@ const LoginPortal = function (props: Props) {
|
|||
// Redirect to the correct stage if not enough authenticated
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
const redirectionSuffix = redirectionURL ? `?rd=${encodeURIComponent(redirectionURL)}` : "";
|
||||
const redirectionSuffix = redirectionURL
|
||||
? `?rd=${encodeURIComponent(redirectionURL)}${requestMethod ? `&rm=${requestMethod}` : ""}`
|
||||
: "";
|
||||
|
||||
if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
|
||||
setFirstFactorDisabled(false);
|
||||
|
@ -103,7 +107,7 @@ const LoginPortal = function (props: Props) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}, [state, redirectionURL, redirect, userInfo, setFirstFactorDisabled, configuration]);
|
||||
}, [state, redirectionURL, requestMethod, redirect, userInfo, setFirstFactorDisabled, configuration]);
|
||||
|
||||
const handleAuthSuccess = async (redirectionURL: string | undefined) => {
|
||||
if (redirectionURL) {
|
||||
|
|
Loading…
Reference in New Issue